Merge branch 'master' of github.com:Netflix/lemur into cname_01

This commit is contained in:
csine-nflx 2020-10-28 14:03:23 -07:00
commit d27f2a53af
67 changed files with 2412 additions and 465 deletions

View File

@ -1,5 +1,5 @@
language: python language: python
dist: xenial dist: bionic
node_js: node_js:
- "6.2.0" - "6.2.0"
@ -20,6 +20,8 @@ cache:
env: env:
global: global:
- PIP_DOWNLOAD_CACHE=".pip_download_cache" - PIP_DOWNLOAD_CACHE=".pip_download_cache"
# The following line is a temporary workaround for this issue: https://github.com/pypa/setuptools/issues/2230
- SETUPTOOLS_USE_DISTUTILS=stdlib
# do not load /etc/boto.cfg with Python 3 incompatible plugin # do not load /etc/boto.cfg with Python 3 incompatible plugin
# https://github.com/travis-ci/travis-ci/issues/5246#issuecomment-166460882 # https://github.com/travis-ci/travis-ci/issues/5246#issuecomment-166460882
- BOTO_CONFIG=/doesnotexist - BOTO_CONFIG=/doesnotexist

View File

@ -1,4 +1,7 @@
import os import os
import random
import string
import base64
from ast import literal_eval from ast import literal_eval
_basedir = os.path.abspath(os.path.dirname(__file__)) _basedir = os.path.abspath(os.path.dirname(__file__))
@ -6,12 +9,22 @@ _basedir = os.path.abspath(os.path.dirname(__file__))
CORS = os.environ.get("CORS") == "True" CORS = os.environ.get("CORS") == "True"
debug = os.environ.get("DEBUG") == "True" debug = os.environ.get("DEBUG") == "True"
SECRET_KEY = repr(os.environ.get('SECRET_KEY','Hrs8kCDNPuT9vtshsSWzlrYW+d+PrAXvg/HwbRE6M3vzSJTTrA/ZEw=='))
LEMUR_TOKEN_SECRET = repr(os.environ.get('LEMUR_TOKEN_SECRET','YVKT6nNHnWRWk28Lra1OPxMvHTqg1ZXvAcO7bkVNSbrEuDQPABM0VQ==')) def get_random_secret(length):
LEMUR_ENCRYPTION_KEYS = repr(os.environ.get('LEMUR_ENCRYPTION_KEYS','Ls-qg9j3EMFHyGB_NL0GcQLI6622n9pSyGM_Pu0GdCo=')) secret_key = ''.join(random.choice(string.ascii_uppercase) for x in range(round(length / 4)))
secret_key = secret_key + ''.join(random.choice("~!@#$%^&*()_+") for x in range(round(length / 4)))
secret_key = secret_key + ''.join(random.choice(string.ascii_lowercase) for x in range(round(length / 4)))
return secret_key + ''.join(random.choice(string.digits) for x in range(round(length / 4)))
LEMUR_WHITELISTED_DOMAINS = []
SECRET_KEY = repr(os.environ.get('SECRET_KEY', get_random_secret(32).encode('utf8')))
LEMUR_TOKEN_SECRET = repr(os.environ.get('LEMUR_TOKEN_SECRET',
base64.b64encode(get_random_secret(32).encode('utf8'))))
LEMUR_ENCRYPTION_KEYS = repr(os.environ.get('LEMUR_ENCRYPTION_KEYS',
base64.b64encode(get_random_secret(32).encode('utf8'))))
LEMUR_ALLOWED_DOMAINS = []
LEMUR_EMAIL = '' LEMUR_EMAIL = ''
LEMUR_SECURITY_TEAM_EMAIL = [] LEMUR_SECURITY_TEAM_EMAIL = []

View File

@ -100,7 +100,7 @@ Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create c
Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True. Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True.
.. data:: LEMUR_WHITELISTED_DOMAINS .. data:: LEMUR_ALLOWED_DOMAINS
:noindex: :noindex:
List of regular expressions for domain restrictions; if the list is not empty, normal users can only issue List of regular expressions for domain restrictions; if the list is not empty, normal users can only issue
@ -155,32 +155,28 @@ Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create c
LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s='] LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s=']
.. data:: PUBLIC_CA_AUTHORITY_NAMES
:noindex:
A list of public issuers which would be checked against to determine whether limit of max validity of 397 days
should be applied to the certificate. Configure public CA authority names in this list to enforce validity check.
This is an optional setting. Using this will allow the sanity check as mentioned. The name check is a case-insensitive
string comparision.
.. data:: PUBLIC_CA_MAX_VALIDITY_DAYS .. data:: PUBLIC_CA_MAX_VALIDITY_DAYS
:noindex: :noindex:
Use this config to override the limit of 397 days of validity for certificates issued by public issuers configured Use this config to override the limit of 397 days of validity for certificates issued by CA/Browser compliant authorities.
using PUBLIC_CA_AUTHORITY_NAMES. Below example overrides the default validity of 397 days and sets it to 365 days. The authorities with cab_compliant option set to true will use this config. The example below overrides the default validity
of 397 days and sets it to 365 days.
:: ::
PUBLIC_CA_MAX_VALIDITY_DAYS = 365 PUBLIC_CA_MAX_VALIDITY_DAYS = 365
.. data:: DEFAULT_MAX_VALIDITY_DAYS .. data:: DEFAULT_VALIDITY_DAYS
:noindex: :noindex:
Use this config to override the default limit of 1095 days (3 years) of validity. Any CA which is not listed in Use this config to override the default validity of 365 days for certificates offered through Lemur UI. Any CA which
PUBLIC_CA_AUTHORITY_NAMES will be using this validity to display date range on UI. Below example overrides the is not CA/Browser Forum compliant will be using this value as default validity to be displayed on UI. Please
default validity of 1095 days and sets it to 365 days. note that this config is used for cert issuance only through Lemur UI. The example below overrides the default validity
of 365 days and sets it to 1095 days (3 years).
:: ::
DEFAULT_MAX_VALIDITY_DAYS = 365 DEFAULT_VALIDITY_DAYS = 1095
.. data:: DEBUG_DUMP .. data:: DEBUG_DUMP
@ -273,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 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. 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 .. data:: LEMUR_EMAIL_SENDER
@ -327,6 +323,54 @@ Lemur supports sending certification expiration notifications through SES and SM
LEMUR_SECURITY_TEAM_EMAIL_INTERVALS = [15, 2] LEMUR_SECURITY_TEAM_EMAIL_INTERVALS = [15, 2]
Celery Options
---------------
To make use of automated tasks within lemur (e.g. syncing source/destinations, or reissuing ACME certificates), you
need to configure celery. See :ref:`Periodic Tasks <PeriodicTasks>` for more in depth documentation.
.. data:: CELERY_RESULT_BACKEND
:noindex:
The url to your redis backend (needs to be in the format `redis://<host>:<port>/<database>`)
.. data:: CELERY_BROKER_URL
:noindex:
The url to your redis broker (needs to be in the format `redis://<host>:<port>/<database>`)
.. data:: CELERY_IMPORTS
:noindex:
The module that celery needs to import, in our case thats `lemur.common.celery`
.. data:: CELERY_TIMEZONE
:noindex:
The timezone for celery to work with
.. data:: CELERYBEAT_SCHEDULE
:noindex:
This defines the schedule, with which the celery beat makes the worker run the specified tasks.
Since the celery module, relies on the RedisHandler, the following options also need to be set.
.. data:: REDIS_HOST
:noindex:
Hostname of your redis instance
.. data:: REDIS_PORT
:noindex:
Port on which redis is running (default: 6379)
.. data:: REDIS_DB
:noindex:
Which redis database to be used, by default redis offers databases 0-15 (default: 0)
Authentication Options Authentication Options
---------------------- ----------------------
Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily. Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
@ -667,12 +711,19 @@ Active Directory Certificate Services Plugin
Template to be used for certificate issuing. Usually display name w/o spaces Template to be used for certificate issuing. Usually display name w/o spaces
.. data:: ADCS_TEMPLATE_<upper(authority.name)>
:noindex:
If there is a config variable ADCS_TEMPLATE_<upper(authority.name)> take the value as Cert template else default to ADCS_TEMPLATE to be compatible with former versions. Template to be used for certificate issuing. Usually display name w/o spaces
.. data:: ADCS_START .. data:: ADCS_START
:noindex: :noindex:
Used in ADCS-Sourceplugin. Minimum id of the first certificate to be returned. ID is increased by one until ADCS_STOP. Missing cert-IDs are ignored
.. data:: ADCS_STOP .. data:: ADCS_STOP
:noindex: :noindex:
Used for ADCS-Sourceplugin. Maximum id of the certificates returned.
.. data:: ADCS_ISSUING .. data:: ADCS_ISSUING
:noindex: :noindex:
@ -685,6 +736,68 @@ Active Directory Certificate Services Plugin
Contains the root cert of the CA Contains the root cert of the CA
Entrust Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Enables the creation of Entrust certificates. You need to set the API access up with Entrust support. Check the information in the Entrust Portal as well.
Certificates are created as "SERVER_AND_CLIENT_AUTH".
Caution: Sometimes the entrust API does not respond in a timely manner. This error is handled and reported by the plugin. Should this happen you just have to hit the create button again after to create a valid certificate.
The following parameters have to be set in the configuration files.
.. data:: ENTRUST_URL
:noindex:
This is the url for the Entrust API. Refer to the API documentation.
.. data:: ENTRUST_API_CERT
:noindex:
Path to the certificate file in PEM format. This certificate is created in the onboarding process. Refer to the API documentation.
.. data:: ENTRUST_API_KEY
:noindex:
Path to the key file in RSA format. This certificate is created in the onboarding process. Refer to the API documentation. Caution: the request library cannot handle encrypted keys. The keyfile therefore has to contain the unencrypted key. Please put this in a secure location on the server.
.. data:: ENTRUST_API_USER
:noindex:
String with the API user. This user is created in the onboarding process. Refer to the API documentation.
.. data:: ENTRUST_API_PASS
:noindex:
String with the password for the API user. This password is created in the onboarding process. Refer to the API documentation.
.. data:: ENTRUST_NAME
:noindex:
String with the name that should appear as certificate owner in the Entrust portal. Refer to the API documentation.
.. data:: ENTRUST_EMAIL
:noindex:
String with the email address that should appear as certificate contact email in the Entrust portal. Refer to the API documentation.
.. data:: ENTRUST_PHONE
:noindex:
String with the phone number that should appear as certificate contact in the Entrust portal. Refer to the API documentation.
.. data:: ENTRUST_ISSUING
:noindex:
Contains the issuing cert of the CA
.. data:: ENTRUST_ROOT
:noindex:
Contains the root cert of the CA
.. data:: ENTRUST_PRODUCT_<upper(authority.name)>
:noindex:
If there is a config variable ENTRUST_PRODUCT_<upper(authority.name)> take the value as cert product name else default to "STANDARD_SSL". Refer to the API documentation for valid products names.
Verisign Issuer Plugin Verisign Issuer Plugin
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
@ -1067,6 +1180,23 @@ The following configuration properties are required to use the PowerDNS ACME Plu
File/Dir path to CA Bundle: Verifies the TLS certificate was issued by a Certificate Authority in the provided CA bundle. File/Dir path to CA Bundle: Verifies the TLS certificate was issued by a Certificate Authority in the provided CA bundle.
ACME Plugin
~~~~~~~~~~~~
The following configration properties are optional for the ACME plugin to use. They allow reusing an existing ACME
account. See :ref:`Using a pre-existing ACME account <AcmeAccountReuse>` for more details.
.. data:: ACME_PRIVATE_KEY
:noindex:
This is the private key, the account was registered with (in JWK format)
.. data:: ACME_REGR
:noindex:
This is the registration for the ACME account, the most important part is the uri attribute (in JSON)
.. _CommandLineInterface: .. _CommandLineInterface:
Command Line Interface Command Line Interface
@ -1320,7 +1450,7 @@ Slack
Adds support for slack notifications. Adds support for slack notifications.
AWS AWS (Source)
---- ----
:Authors: :Authors:
@ -1333,7 +1463,7 @@ AWS
Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment. Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment.
AWS AWS (Destination)
---- ----
:Authors: :Authors:
@ -1346,6 +1476,19 @@ AWS
Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment. Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment.
AWS (SNS Notification)
-----
:Authors:
Jasmine Schladen <jschladen@netflix.com>
: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 Kubernetes
---------- ----------

View File

@ -215,18 +215,21 @@ Notification
------------ ------------
Lemur includes the ability to create Email notifications by **default**. These notifications 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 certificate expiration dates and
determines if a given certificate is eligible for notification. There are currently only two parameters used to 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 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. 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 Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable.
any notification within Lemur. Currently the only support notification type is an certificate expiration notification. If you 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 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. 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. 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. The second is `ExpirationNotificationPlugin`, which inherits from the `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 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:: 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): def send(self, notification_type, message, targets, options, **kwargs):

View File

@ -49,9 +49,11 @@ The amount of effort you wish to expend ensuring that Lemur has good entropy to
If you wish to generate more entropy for your system we would suggest you take a look at the following resources: If you wish to generate more entropy for your system we would suggest you take a look at the following resources:
- `WES-entropy-client <https://github.com/WhitewoodCrypto/WES-entropy-client>`_ - `WES-entropy-client <https://github.com/Virginian/WES-entropy-client>`_
- `haveged <http://www.issihosts.com/haveged/>`_ - `haveged <http://www.issihosts.com/haveged/>`_
The original *WES-entropy-client* repository by WhitewoodCrypto was removed, the link now points to a fork of it.
For additional information about OpenSSL entropy issues: For additional information about OpenSSL entropy issues:
- `Managing and Understanding Entropy Usage <https://www.blackhat.com/docs/us-15/materials/us-15-Potter-Understanding-And-Managing-Entropy-Usage.pdf>`_ - `Managing and Understanding Entropy Usage <https://www.blackhat.com/docs/us-15/materials/us-15-Potter-Understanding-And-Managing-Entropy-Usage.pdf>`_
@ -313,6 +315,7 @@ It will start a shell from which you can start/stop/restart the service.
You can read all errors that might occur from /tmp/lemur.log. You can read all errors that might occur from /tmp/lemur.log.
.. _PeriodicTasks:
Periodic Tasks Periodic Tasks
============== ==============
@ -386,10 +389,17 @@ To enable celery support, you must also have configuration values that tell Cele
Here are the Celery configuration variables that should be set:: Here are the Celery configuration variables that should be set::
CELERY_RESULT_BACKEND = 'redis://your_redis_url:6379' CELERY_RESULT_BACKEND = 'redis://your_redis_url:6379'
CELERY_BROKER_URL = 'redis://your_redis_url:6379' CELERY_BROKER_URL = 'redis://your_redis_url:6379/0'
CELERY_IMPORTS = ('lemur.common.celery') CELERY_IMPORTS = ('lemur.common.celery')
CELERY_TIMEZONE = 'UTC' CELERY_TIMEZONE = 'UTC'
REDIS_HOST="your_redis_url"
REDIS_PORT=6379
REDIS_DB=0
Out of the box, every Redis instance supports 16 databases. The default database (`REDIS_DB`) is set to 0, however, you can use any of the databases from 0-15. Via `redis.conf` more databases can be supported.
In the `redis://` url, the database number can be added with a slash after the port. (defaults to 0, if omitted)
Do not forget to import crontab module in your configuration file:: Do not forget to import crontab module in your configuration file::
from celery.task.schedules import crontab from celery.task.schedules import crontab
@ -501,3 +511,47 @@ The following must be added to the config file to activate the pinning (the pinn
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg== KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
-----END CERTIFICATE----- -----END CERTIFICATE-----
""" """
.. _AcmeAccountReuse:
LetsEncrypt: Using a pre-existing ACME account
-----------------------------------------------
Let's Encrypt allows reusing an existing ACME account, to create and especially revoke certificates. The current
implementation in the acme plugin, only allows for a single account for all ACME authorities, which might be an issue,
when you try to use Let's Encrypt together with another certificate authority that uses the ACME protocol.
To use an existing account, you need to configure the `ACME_PRIVATE_KEY` and `ACME_REGR` variables in the lemur
configuration.
`ACME_PRIVATE_KEY` needs to be in the JWK format::
{
"kty": "RSA",
"n": "yr1qBwHizA7ME_iV32bY10ILp.....",
"e": "AQAB",
"d": "llBlYhil3I.....",
"p": "-5LW2Lewogo.........",
"q": "zk6dHqHfHksd.........",
"dp": "qfe9fFIu3mu.......",
"dq": "cXFO-loeOyU.......",
"qi": "AfK1sh0_8sLTb..........."
}
Using `python-jwt` converting an existing private key in PEM format is quite easy::
import python_jwt as jwt, jwcrypto.jwk as jwk
priv_key = jwk.JWK.from_pem(b"""-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----""")
print(priv_key.export())
`ACME_REGR` needs to be a valid JSON with a `body` and a `uri` attribute, similar to this::
{"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/<ACCOUNT_NUMBER>"}
The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key.

View File

@ -6,6 +6,9 @@
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import json
from flask import current_app
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy import ( from sqlalchemy import (
Column, Column,
@ -80,5 +83,33 @@ class Authority(db.Model):
def plugin(self): def plugin(self):
return plugins.get(self.plugin_name) return plugins.get(self.plugin_name)
@property
def is_cab_compliant(self):
"""
Parse the options to find whether authority is CAB Forum Compliant,
i.e., adhering to the CA/Browser Forum Baseline Requirements.
Returns None if option is not available
"""
if not self.options:
return None
for option in json.loads(self.options):
if "name" in option and option["name"] == 'cab_compliant':
return option["value"]
return None
@property
def max_issuance_days(self):
if self.is_cab_compliant:
return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397)
@property
def default_validity_days(self):
if self.is_cab_compliant:
return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397)
return current_app.config.get("DEFAULT_VALIDITY_DAYS", 365) # 1 year default
def __repr__(self): def __repr__(self):
return "Authority(name={name})".format(name=self.name) return "Authority(name={name})".format(name=self.name)

View File

@ -23,6 +23,7 @@ from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.common import validators, missing from lemur.common import validators, missing
from lemur.common.fields import ArrowDateTime from lemur.common.fields import ArrowDateTime
from lemur.constants import CERTIFICATE_KEY_TYPES
class AuthorityInputSchema(LemurInputSchema): class AuthorityInputSchema(LemurInputSchema):
@ -42,13 +43,13 @@ class AuthorityInputSchema(LemurInputSchema):
organization = fields.String( organization = fields.String(
missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION") missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION")
) )
location = fields.String( location = fields.String()
missing=lambda: current_app.config.get("LEMUR_DEFAULT_LOCATION")
)
country = fields.String( country = fields.String(
missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY") missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY")
) )
state = fields.String(missing=lambda: current_app.config.get("LEMUR_DEFAULT_STATE")) state = fields.String(missing=lambda: current_app.config.get("LEMUR_DEFAULT_STATE"))
# Creating a String field instead of Email to allow empty value
email = fields.String()
plugin = fields.Nested(PluginInputSchema) plugin = fields.Nested(PluginInputSchema)
@ -56,11 +57,12 @@ class AuthorityInputSchema(LemurInputSchema):
type = fields.String(validate=validate.OneOf(["root", "subca"]), missing="root") type = fields.String(validate=validate.OneOf(["root", "subca"]), missing="root")
parent = fields.Nested(AssociatedAuthoritySchema) parent = fields.Nested(AssociatedAuthoritySchema)
signing_algorithm = fields.String( signing_algorithm = fields.String(
validate=validate.OneOf(["sha256WithRSA", "sha1WithRSA"]), validate=validate.OneOf(["sha256WithRSA", "sha1WithRSA",
"sha256WithECDSA", "SHA384withECDSA", "SHA512withECDSA"]),
missing="sha256WithRSA", missing="sha256WithRSA",
) )
key_type = fields.String( key_type = fields.String(
validate=validate.OneOf(["RSA2048", "RSA4096"]), missing="RSA2048" validate=validate.OneOf(CERTIFICATE_KEY_TYPES), missing="RSA2048"
) )
key_name = fields.String() key_name = fields.String()
sensitivity = fields.String( sensitivity = fields.String(
@ -109,7 +111,6 @@ class RootAuthorityCertificateOutputSchema(LemurOutputSchema):
cn = fields.String() cn = fields.String()
not_after = fields.DateTime() not_after = fields.DateTime()
not_before = fields.DateTime() not_before = fields.DateTime()
max_issuance_days = fields.Integer()
owner = fields.Email() owner = fields.Email()
status = fields.Boolean() status = fields.Boolean()
user = fields.Nested(UserNestedOutputSchema) user = fields.Nested(UserNestedOutputSchema)
@ -124,6 +125,8 @@ class AuthorityOutputSchema(LemurOutputSchema):
active = fields.Boolean() active = fields.Boolean()
options = fields.Dict() options = fields.Dict()
roles = fields.List(fields.Nested(AssociatedRoleSchema)) roles = fields.List(fields.Nested(AssociatedRoleSchema))
max_issuance_days = fields.Integer()
default_validity_days = fields.Integer()
authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema) authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema)
@ -135,7 +138,10 @@ class AuthorityNestedOutputSchema(LemurOutputSchema):
owner = fields.Email() owner = fields.Email()
plugin = fields.Nested(PluginOutputSchema) plugin = fields.Nested(PluginOutputSchema)
active = fields.Boolean() active = fields.Boolean()
authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema, only=["max_issuance_days"]) authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema, only=["not_after", "not_before"])
is_cab_compliant = fields.Boolean()
max_issuance_days = fields.Integer()
default_validity_days = fields.Integer()
authority_update_schema = AuthorityUpdateSchema() authority_update_schema = AuthorityUpdateSchema()

View File

@ -39,6 +39,22 @@ def update(authority_id, description, owner, active, roles):
return database.update(authority) return database.update(authority)
def update_options(authority_id, options):
"""
Update an authority with new options.
:param authority_id:
:param options: the new options to be saved into the authority
:return:
"""
authority = get(authority_id)
authority.options = options
return database.update(authority)
def mint(**kwargs): def mint(**kwargs):
""" """
Creates the authority based on the plugin provided. Creates the authority based on the plugin provided.

View File

@ -735,3 +735,45 @@ def automatically_enable_autorotate():
}) })
cert.rotation = True cert.rotation = True
database.update(cert) database.update(cert)
@manager.command
def deactivate_entrust_certificates():
"""
Attempt to deactivate test certificates issued by Entrust
"""
log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Deactivating Entrust certificates"
}
certificates = get_all_valid_certs(['entrust-issuer'])
entrust_plugin = plugins.get('entrust-issuer')
for cert in certificates:
try:
response = entrust_plugin.deactivate_certificate(cert)
if response == 200:
cert.status = "revoked"
else:
cert.status = "unknown"
log_data["valid"] = cert.status
log_data["certificate_name"] = cert.name
log_data["certificate_id"] = cert.id
metrics.send(
"certificate_deactivate",
"counter",
1,
metric_tags={"status": log_data["valid"],
"certificate_name": log_data["certificate_name"],
"certificate_id": log_data["certificate_id"]},
)
current_app.logger.info(log_data)
database.update(cert)
except Exception as e:
current_app.logger.info(log_data)
sentry.captureException()
current_app.logger.exception(e)

View File

@ -9,7 +9,6 @@ from datetime import timedelta
import arrow import arrow
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa
from flask import current_app from flask import current_app
from idna.core import InvalidCodepoint from idna.core import InvalidCodepoint
from sqlalchemy import ( from sqlalchemy import (
@ -153,6 +152,7 @@ class Certificate(db.Model):
Integer, ForeignKey("authorities.id", ondelete="CASCADE") Integer, ForeignKey("authorities.id", ondelete="CASCADE")
) )
rotation_policy_id = Column(Integer, ForeignKey("rotation_policies.id")) rotation_policy_id = Column(Integer, ForeignKey("rotation_policies.id"))
key_type = Column(String(128))
notifications = relationship( notifications = relationship(
"Notification", "Notification",
@ -235,6 +235,7 @@ class Certificate(db.Model):
self.replaces = kwargs.get("replaces", []) self.replaces = kwargs.get("replaces", [])
self.rotation = kwargs.get("rotation") self.rotation = kwargs.get("rotation")
self.rotation_policy = kwargs.get("rotation_policy") self.rotation_policy = kwargs.get("rotation_policy")
self.key_type = kwargs.get("key_type")
self.signing_algorithm = defaults.signing_algorithm(cert) self.signing_algorithm = defaults.signing_algorithm(cert)
self.bits = defaults.bitstrength(cert) self.bits = defaults.bitstrength(cert)
self.external_id = kwargs.get("external_id") self.external_id = kwargs.get("external_id")
@ -296,12 +297,17 @@ class Certificate(db.Model):
def distinguished_name(self): def distinguished_name(self):
return self.parsed_cert.subject.rfc4514_string() return self.parsed_cert.subject.rfc4514_string()
"""
# Commenting this property as key_type is now added as a column. This code can be removed in future.
@property @property
def key_type(self): def key_type(self):
if isinstance(self.parsed_cert.public_key(), rsa.RSAPublicKey): if isinstance(self.parsed_cert.public_key(), rsa.RSAPublicKey):
return "RSA{key_size}".format( return "RSA{key_size}".format(
key_size=self.parsed_cert.public_key().key_size key_size=self.parsed_cert.public_key().key_size
) )
elif isinstance(self.parsed_cert.public_key(), ec.EllipticCurvePublicKey):
return get_key_type_from_ec_curve(self.parsed_cert.public_key().curve.name)
"""
@property @property
def validity_remaining(self): def validity_remaining(self):
@ -311,14 +317,6 @@ class Certificate(db.Model):
def validity_range(self): def validity_range(self):
return self.not_after - self.not_before return self.not_after - self.not_before
@property
def max_issuance_days(self):
public_CA = current_app.config.get("PUBLIC_CA_AUTHORITY_NAMES", [])
if self.name.lower() in [ca.lower() for ca in public_CA]:
return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397)
return current_app.config.get("DEFAULT_MAX_VALIDITY_DAYS", 1095) # 3 years default
@property @property
def subject(self): def subject(self):
return self.parsed_cert.subject return self.parsed_cert.subject

View File

@ -8,7 +8,7 @@
from flask import current_app from flask import current_app
from flask_restful import inputs from flask_restful import inputs
from flask_restful.reqparse import RequestParser from flask_restful.reqparse import RequestParser
from marshmallow import fields, validate, validates_schema, post_load, pre_load from marshmallow import fields, validate, validates_schema, post_load, pre_load, post_dump
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
from lemur.authorities.schemas import AuthorityNestedOutputSchema from lemur.authorities.schemas import AuthorityNestedOutputSchema
@ -23,6 +23,7 @@ from lemur.domains.schemas import DomainNestedOutputSchema
from lemur.notifications import service as notification_service from lemur.notifications import service as notification_service
from lemur.notifications.schemas import NotificationNestedOutputSchema from lemur.notifications.schemas import NotificationNestedOutputSchema
from lemur.policies.schemas import RotationPolicyNestedOutputSchema from lemur.policies.schemas import RotationPolicyNestedOutputSchema
from lemur.roles import service as roles_service
from lemur.roles.schemas import RoleNestedOutputSchema from lemur.roles.schemas import RoleNestedOutputSchema
from lemur.schemas import ( from lemur.schemas import (
AssociatedAuthoritySchema, AssociatedAuthoritySchema,
@ -107,9 +108,7 @@ class CertificateInputSchema(CertificateCreationSchema):
organization = fields.String( organization = fields.String(
missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION") missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION")
) )
location = fields.String( location = fields.String()
missing=lambda: current_app.config.get("LEMUR_DEFAULT_LOCATION")
)
country = fields.String( country = fields.String(
missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY") missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY")
) )
@ -148,6 +147,21 @@ class CertificateInputSchema(CertificateCreationSchema):
data["extensions"]["subAltNames"]["names"] = [] data["extensions"]["subAltNames"]["names"] = []
data["extensions"]["subAltNames"]["names"] = csr_sans data["extensions"]["subAltNames"]["names"] = csr_sans
common_name = cert_utils.get_cn_from_csr(data["csr"])
if common_name:
data["common_name"] = common_name
key_type = cert_utils.get_key_type_from_csr(data["csr"])
if key_type:
data["key_type"] = key_type
# This code will be exercised for certificate import (without CSR)
if data.get("key_type") is None:
if data.get("body"):
data["key_type"] = utils.get_key_type_from_certificate(data["body"])
else:
data["key_type"] = "RSA2048" # default value
return missing.convert_validity_years(data) return missing.convert_validity_years(data)
@ -171,25 +185,52 @@ class CertificateEditInputSchema(CertificateSchema):
data["replaces"] = data[ data["replaces"] = data[
"replacements" "replacements"
] # TODO remove when field is deprecated ] # TODO remove when field is deprecated
if data.get("owner"):
# Check if role already exists. This avoids adding duplicate role.
if data.get("roles") and any(r.get("name") == data["owner"] for r in data["roles"]):
return data
# Add required role
owner_role = roles_service.get_or_create(
data["owner"],
description=f"Auto generated role based on owner: {data['owner']}"
)
# Put role info in correct format using RoleNestedOutputSchema
owner_role_dict = RoleNestedOutputSchema().dump(owner_role).data
if data.get("roles"):
data["roles"].append(owner_role_dict)
else:
data["roles"] = [owner_role_dict]
return data return data
@post_load @post_load
def enforce_notifications(self, data): def enforce_notifications(self, data):
""" """
Ensures that when an owner changes, default notifications are added for the new owner. Add default notification for current owner if none exist.
Old owner notifications are retained unless explicitly removed. This ensures that the default notifications are added in the event of owner change.
Old owner notifications are retained unless explicitly removed later in the code path.
:param data: :param data:
:return: :return:
""" """
if data["owner"]: if data.get("owner"):
notification_name = "DEFAULT_{0}".format( notification_name = "DEFAULT_{0}".format(
data["owner"].split("@")[0].upper() data["owner"].split("@")[0].upper()
) )
# Even if one default role exists, return
# This allows a User to remove unwanted default notification for current owner
if any(n.label.startswith(notification_name) for n in data["notifications"]):
return data
data[ data[
"notifications" "notifications"
] += notification_service.create_default_expiration_notifications( ] += notification_service.create_default_expiration_notifications(
notification_name, [data["owner"]] notification_name, [data["owner"]]
) )
return data return data
@ -270,6 +311,7 @@ class CertificateOutputSchema(LemurOutputSchema):
serial = fields.String() serial = fields.String()
serial_hex = Hex(attribute="serial") serial_hex = Hex(attribute="serial")
signing_algorithm = fields.String() signing_algorithm = fields.String()
key_type = fields.String(allow_none=True)
status = fields.String() status = fields.String()
user = fields.Nested(UserNestedOutputSchema) user = fields.Nested(UserNestedOutputSchema)
@ -290,6 +332,31 @@ class CertificateOutputSchema(LemurOutputSchema):
) )
rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema) rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema)
country = fields.String()
location = fields.String()
state = fields.String()
organization = fields.String()
organizational_unit = fields.String()
@post_dump
def handle_subject_details(self, data):
subject_details = ["country", "state", "location", "organization", "organizational_unit"]
# Remove subject details if authority is CA/Browser Forum compliant. The code will use default set of values in that case.
# If CA/Browser Forum compliance of an authority is unknown (None), it is safe to fallback to default values. Thus below
# condition checks for 'not False' ==> 'True or None'
if data.get("authority"):
is_cab_compliant = data.get("authority").get("isCabCompliant")
if is_cab_compliant is not False:
for field in subject_details:
data.pop(field, None)
# Removing subject fields if None, else it complains in de-serialization
for field in subject_details:
if field in data and data[field] is None:
data.pop(field)
class CertificateShortOutputSchema(LemurOutputSchema): class CertificateShortOutputSchema(LemurOutputSchema):
id = fields.Integer() id = fields.Integer()
@ -310,6 +377,7 @@ class CertificateUploadInputSchema(CertificateCreationSchema):
body = fields.String(required=True) body = fields.String(required=True)
chain = fields.String(missing=None, allow_none=True) chain = fields.String(missing=None, allow_none=True)
csr = fields.String(required=False, allow_none=True, validate=validators.csr) csr = fields.String(required=False, allow_none=True, validate=validators.csr)
key_type = fields.String()
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True) destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True) notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
@ -357,6 +425,16 @@ class CertificateUploadInputSchema(CertificateCreationSchema):
# Throws ValidationError # Throws ValidationError
validators.verify_cert_chain([cert] + chain) validators.verify_cert_chain([cert] + chain)
@pre_load
def load_data(self, data):
if data.get("body"):
try:
data["key_type"] = utils.get_key_type_from_certificate(data["body"])
except ValueError:
raise ValidationError(
"Public certificate presented is not valid.", field_names=["body"]
)
class CertificateExportInputSchema(LemurInputSchema): class CertificateExportInputSchema(LemurInputSchema):
plugin = fields.Nested(PluginInputSchema) plugin = fields.Nested(PluginInputSchema)

View File

@ -105,7 +105,7 @@ def get_all_certs():
def get_all_valid_certs(authority_plugin_name): def get_all_valid_certs(authority_plugin_name):
""" """
Retrieves all valid (not expired) certificates within Lemur, for the given authority plugin names Retrieves all valid (not expired & not revoked) certificates within Lemur, for the given authority plugin names
ignored if no authority_plugin_name provided. ignored if no authority_plugin_name provided.
Note that depending on the DB size retrieving all certificates might an expensive operation Note that depending on the DB size retrieving all certificates might an expensive operation
@ -116,11 +116,12 @@ def get_all_valid_certs(authority_plugin_name):
return ( return (
Certificate.query.outerjoin(Authority, Authority.id == Certificate.authority_id).filter( Certificate.query.outerjoin(Authority, Authority.id == Certificate.authority_id).filter(
Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter( Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter(
Authority.plugin_name.in_(authority_plugin_name)).all() Authority.plugin_name.in_(authority_plugin_name)).filter(Certificate.revoked.is_(False)).all()
) )
else: else:
return ( return (
Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).all() Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter(
Certificate.revoked.is_(False)).all()
) )
@ -256,17 +257,29 @@ def update(cert_id, **kwargs):
return database.update(cert) return database.update(cert)
def create_certificate_roles(**kwargs): def cleanup_owner_roles_notification(owner_name, kwargs):
# create an role for the owner and assign it kwargs["roles"] = [r for r in kwargs["roles"] if r.name != owner_name]
owner_role = role_service.get_by_name(kwargs["owner"]) notification_prefix = f"DEFAULT_{owner_name.split('@')[0].upper()}"
kwargs["notifications"] = [n for n in kwargs["notifications"] if not n.label.startswith(notification_prefix)]
if not owner_role:
owner_role = role_service.create( def update_notify(cert, notify_flag):
kwargs["owner"], """
description="Auto generated role based on owner: {0}".format( Toggle notification value which is a boolean
kwargs["owner"] :param notify_flag: new notify value
), :param cert: Certificate object to be updated
) :return:
"""
cert.notify = notify_flag
return database.update(cert)
def create_certificate_roles(**kwargs):
# create a role for the owner and assign it
owner_role = role_service.get_or_create(
kwargs["owner"],
description=f"Auto generated role based on owner: {kwargs['owner']}"
)
# ensure that the authority's owner is also associated with the certificate # ensure that the authority's owner is also associated with the certificate
if kwargs.get("authority"): if kwargs.get("authority"):
@ -347,7 +360,12 @@ def create(**kwargs):
try: try:
cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs) cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs)
except Exception: except Exception:
current_app.logger.error("Exception minting certificate", exc_info=True) log_data = {
"message": "Exception minting certificate",
"issuer": kwargs["authority"].name,
"cn": kwargs["common_name"],
}
current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
raise raise
kwargs["body"] = cert_body kwargs["body"] = cert_body
@ -542,20 +560,21 @@ def query_common_name(common_name, args):
:return: :return:
""" """
owner = args.pop("owner") owner = args.pop("owner")
if not owner:
owner = "%"
# only not expired certificates # only not expired certificates
current_time = arrow.utcnow() current_time = arrow.utcnow()
result = ( query = Certificate.query.filter(Certificate.not_after >= current_time.format("YYYY-MM-DD"))\
Certificate.query.filter(Certificate.cn.ilike(common_name)) .filter(not_(Certificate.revoked))\
.filter(Certificate.owner.ilike(owner)) .filter(not_(Certificate.replaced.any())) # ignore rotated certificates to avoid duplicates
.filter(Certificate.not_after >= current_time.format("YYYY-MM-DD"))
.all()
)
return result if owner:
query = query.filter(Certificate.owner.ilike(owner))
if common_name != "%":
# if common_name is a wildcard ('%'), no need to include it in the query
query = query.filter(Certificate.cn.ilike(common_name))
return query.all()
def create_csr(**csr_config): def create_csr(**csr_config):

View File

@ -12,6 +12,8 @@ Utils to parse certificate data.
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from lemur.common.utils import get_key_type_from_ec_curve
def get_sans_from_csr(data): def get_sans_from_csr(data):
@ -39,3 +41,45 @@ def get_sans_from_csr(data):
pass pass
return sub_alt_names return sub_alt_names
def get_cn_from_csr(data):
"""
Fetches common name (CN) from CSR.
Works with any kind of SubjectAlternativeName
:param data: PEM-encoded string with CSR
:return: the common name
"""
try:
request = x509.load_pem_x509_csr(data.encode("utf-8"), default_backend())
except Exception:
raise ValidationError("CSR presented is not valid.")
common_name = request.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
return common_name[0].value
def get_key_type_from_csr(data):
"""
Fetches key_type from CSR.
Works with any kind of SubjectAlternativeName
:param data: PEM-encoded string with CSR
:return: key_type
"""
try:
request = x509.load_pem_x509_csr(data.encode("utf-8"), default_backend())
except Exception:
raise ValidationError("CSR presented is not valid.")
try:
if isinstance(request.public_key(), rsa.RSAPublicKey):
return "RSA{key_size}".format(
key_size=request.public_key().key_size
)
elif isinstance(request.public_key(), ec.EllipticCurvePublicKey):
return get_key_type_from_ec_curve(request.public_key().curve.name)
else:
raise Exception("Unsupported key type")
except NotImplemented:
raise NotImplemented()

View File

@ -884,10 +884,118 @@ class Certificates(AuthenticatedResource):
400, 400,
) )
# if owner is changed, remove all notifications and roles associated with old owner
if cert.owner != data["owner"]:
service.cleanup_owner_roles_notification(cert.owner, data)
cert = service.update(certificate_id, **data) cert = service.update(certificate_id, **data)
log_service.create(g.current_user, "update_cert", certificate=cert) log_service.create(g.current_user, "update_cert", certificate=cert)
return cert return cert
@validate_schema(certificate_edit_input_schema, certificate_output_schema)
def post(self, certificate_id, data=None):
"""
.. http:post:: /certificates/1/update/notify
Update certificate notification
**Example request**:
.. sourcecode:: http
POST /certificates/1/update/notify HTTP/1.1
Host: example.com
Accept: application/json, text/javascript
{
"notify": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"status": null,
"cn": "*.test.example.net",
"chain": "",
"authority": {
"active": true,
"owner": "secure@example.com",
"id": 1,
"description": "verisign test authority",
"name": "verisign"
},
"owner": "joe@example.com",
"serial": "82311058732025924142789179368889309156",
"id": 2288,
"issuer": "SymantecCorporation",
"dateCreated": "2016-06-03T06:09:42.133769+00:00",
"notBefore": "2016-06-03T00:00:00+00:00",
"notAfter": "2018-01-12T23:59:59+00:00",
"destinations": [],
"bits": 2048,
"body": "-----BEGIN CERTIFICATE-----...",
"description": null,
"deleted": null,
"notify": false,
"notifications": [{
"id": 1
}]
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"rotation": true,
"rotationPolicy": {"name": "default"},
"san": null
}
:reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error
:statuscode 403: unauthenticated
"""
cert = service.get(certificate_id)
if not cert:
return dict(message="Cannot find specified certificate"), 404
# allow creators
if g.current_user != cert.user:
owner_role = role_service.get_by_name(cert.owner)
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
if not permission.can():
return (
dict(message="You are not authorized to update this certificate"),
403,
)
cert = service.update_notify(cert, data.get("notify"))
log_service.create(g.current_user, "update_cert", certificate=cert)
return cert
def delete(self, certificate_id, data=None): def delete(self, certificate_id, data=None):
""" """
.. http:delete:: /certificates/1 .. http:delete:: /certificates/1
@ -1354,6 +1462,9 @@ api.add_resource(
api.add_resource( api.add_resource(
Certificates, "/certificates/<int:certificate_id>", endpoint="certificate" Certificates, "/certificates/<int:certificate_id>", endpoint="certificate"
) )
api.add_resource(
Certificates, "/certificates/<int:certificate_id>/update/notify", endpoint="certificateUpdateNotify"
)
api.add_resource(CertificatesStats, "/certificates/stats", endpoint="certificateStats") api.add_resource(CertificatesStats, "/certificates/stats", endpoint="certificateStats")
api.add_resource( api.add_resource(
CertificatesUpload, "/certificates/upload", endpoint="certificateUpload" CertificatesUpload, "/certificates/upload", endpoint="certificateUpload"

View File

@ -759,7 +759,7 @@ def check_revoked():
log_data = { log_data = {
"function": function, "function": function,
"message": "check if any certificates are revoked revoked", "message": "check if any valid certificate is revoked",
"task_id": task_id, "task_id": task_id,
} }
@ -842,3 +842,39 @@ def enable_autorotate_for_certs_attached_to_endpoint():
cli_certificate.automatically_enable_autorotate() cli_certificate.automatically_enable_autorotate()
metrics.send(f"{function}.success", "counter", 1) metrics.send(f"{function}.success", "counter", 1)
return log_data return log_data
@celery.task(soft_time_limit=3600)
def deactivate_entrust_test_certificates():
"""
This celery task attempts to deactivate all not yet deactivated Entrust certificates, and should only run in TEST
:return:
"""
function = f"{__name__}.{sys._getframe().f_code.co_name}"
task_id = None
if celery.current_task:
task_id = celery.current_task.request.id
log_data = {
"function": function,
"message": "deactivate entrust certificates",
"task_id": task_id,
}
if task_id and is_task_active(function, task_id, None):
log_data["message"] = "Skipping task: Task is already active"
current_app.logger.debug(log_data)
return
current_app.logger.debug(log_data)
try:
cli_certificate.deactivate_entrust_certificates()
except SoftTimeLimitExceeded:
log_data["message"] = "Time limit exceeded."
current_app.logger.error(log_data)
sentry.captureException()
metrics.send("celery.timeout", "counter", 1, metric_tags={"function": function})
return
metrics.send(f"{function}.success", "counter", 1)
return log_data

View File

@ -95,9 +95,11 @@ def organization(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[ o = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)
0 if not o:
].value.strip() return None
return o[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get organization! {0}".format(e)) current_app.logger.error("Unable to get organization! {0}".format(e))
@ -110,9 +112,11 @@ def organizational_unit(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME)[ ou = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME)
0 if not ou:
].value.strip() return None
return ou[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get organizational unit! {0}".format(e)) current_app.logger.error("Unable to get organizational unit! {0}".format(e))
@ -125,9 +129,11 @@ def country(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME)[ c = cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME)
0 if not c:
].value.strip() return None
return c[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get country! {0}".format(e)) current_app.logger.error("Unable to get country! {0}".format(e))
@ -140,9 +146,11 @@ def state(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME)[ s = cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME)
0 if not s:
].value.strip() return None
return s[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get state! {0}".format(e)) current_app.logger.error("Unable to get state! {0}".format(e))
@ -155,9 +163,11 @@ def location(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME)[ loc = cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME)
0 if not loc:
].value.strip() return None
return loc[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get location! {0}".format(e)) current_app.logger.error("Unable to get location! {0}".format(e))

View File

@ -9,6 +9,7 @@
import random import random
import re import re
import string import string
import pem
import sqlalchemy import sqlalchemy
from cryptography import x509 from cryptography import x509
@ -16,7 +17,7 @@ from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.primitives.serialization import load_pem_private_key, Encoding, pkcs7
from flask_restful.reqparse import RequestParser from flask_restful.reqparse import RequestParser
from sqlalchemy import and_, func from sqlalchemy import and_, func
@ -71,6 +72,23 @@ def parse_private_key(private_key):
) )
def get_key_type_from_certificate(body):
"""
Helper function to determine key type by pasrding given PEM certificate
:param body: PEM string
:return: Key type string
"""
parsed_cert = parse_certificate(body)
if isinstance(parsed_cert.public_key(), rsa.RSAPublicKey):
return "RSA{key_size}".format(
key_size=parsed_cert.public_key().key_size
)
elif isinstance(parsed_cert.public_key(), ec.EllipticCurvePublicKey):
return get_key_type_from_ec_curve(parsed_cert.public_key().curve.name)
def split_pem(data): def split_pem(data):
""" """
Split a string of several PEM payloads to a list of strings. Split a string of several PEM payloads to a list of strings.
@ -114,6 +132,39 @@ def get_authority_key(body):
return authority_key.hex() return authority_key.hex()
def get_key_type_from_ec_curve(curve_name):
"""
Give an EC curve name, return the matching key_type.
:param: curve_name
:return: key_type
"""
_CURVE_TYPES = {
ec.SECP192R1().name: "ECCPRIME192V1",
ec.SECP256R1().name: "ECCPRIME256V1",
ec.SECP224R1().name: "ECCSECP224R1",
ec.SECP384R1().name: "ECCSECP384R1",
ec.SECP521R1().name: "ECCSECP521R1",
ec.SECP256K1().name: "ECCSECP256K1",
ec.SECT163K1().name: "ECCSECT163K1",
ec.SECT233K1().name: "ECCSECT233K1",
ec.SECT283K1().name: "ECCSECT283K1",
ec.SECT409K1().name: "ECCSECT409K1",
ec.SECT571K1().name: "ECCSECT571K1",
ec.SECT163R2().name: "ECCSECT163R2",
ec.SECT233R1().name: "ECCSECT233R1",
ec.SECT283R1().name: "ECCSECT283R1",
ec.SECT409R1().name: "ECCSECT409R1",
ec.SECT571R1().name: "ECCSECT571R2",
}
if curve_name in _CURVE_TYPES.keys():
return _CURVE_TYPES[curve_name]
else:
return None
def generate_private_key(key_type): def generate_private_key(key_type):
""" """
Generates a new private key based on key_type. Generates a new private key based on key_type.
@ -128,11 +179,11 @@ def generate_private_key(key_type):
""" """
_CURVE_TYPES = { _CURVE_TYPES = {
"ECCPRIME192V1": ec.SECP192R1(), "ECCPRIME192V1": ec.SECP192R1(), # duplicate
"ECCPRIME256V1": ec.SECP256R1(), "ECCPRIME256V1": ec.SECP256R1(), # duplicate
"ECCSECP192R1": ec.SECP192R1(), "ECCSECP192R1": ec.SECP192R1(), # duplicate
"ECCSECP224R1": ec.SECP224R1(), "ECCSECP224R1": ec.SECP224R1(),
"ECCSECP256R1": ec.SECP256R1(), "ECCSECP256R1": ec.SECP256R1(), # duplicate
"ECCSECP384R1": ec.SECP384R1(), "ECCSECP384R1": ec.SECP384R1(),
"ECCSECP521R1": ec.SECP521R1(), "ECCSECP521R1": ec.SECP521R1(),
"ECCSECP256K1": ec.SECP256K1(), "ECCSECP256K1": ec.SECP256K1(),
@ -307,3 +358,19 @@ def find_matching_certificates_by_hash(cert, matching_certs):
): ):
matching.append(c) matching.append(c)
return matching return matching
def convert_pkcs7_bytes_to_pem(certs_pkcs7):
"""
Given a list of certificates in pkcs7 encoding (bytes), covert them into a list of PEM encoded files
:raises ValueError or ValidationError
:param certs_pkcs7:
:return: list of certs in PEM format
"""
certificates = pkcs7.load_pem_pkcs7_certificates(certs_pkcs7)
certificates_pem = []
for cert in certificates:
certificates_pem.append(pem.parse(cert.public_bytes(encoding=Encoding.PEM))[0])
return certificates_pem

View File

@ -22,7 +22,7 @@ def common_name(value):
def sensitive_domain(domain): def sensitive_domain(domain):
""" """
Checks if user has the admin role, the domain does not match sensitive domains and whitelisted domain patterns. Checks if user has the admin role, the domain does not match sensitive domains and allowed domain patterns.
:param domain: domain name (str) :param domain: domain name (str)
:return: :return:
""" """
@ -30,10 +30,10 @@ def sensitive_domain(domain):
# User has permission, no need to check anything # User has permission, no need to check anything
return return
whitelist = current_app.config.get("LEMUR_WHITELISTED_DOMAINS", []) allowlist = current_app.config.get("LEMUR_ALLOWED_DOMAINS", [])
if whitelist and not any(re.match(pattern, domain) for pattern in whitelist): if allowlist and not any(re.match(pattern, domain) for pattern in allowlist):
raise ValidationError( raise ValidationError(
"Domain {0} does not match whitelisted domain patterns. " "Domain {0} does not match allowed domain patterns. "
"Contact an administrator to issue the certificate.".format(domain) "Contact an administrator to issue the certificate.".format(domain)
) )

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, text, Text from sqlalchemy import Column, Integer, String, text
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
@ -12,7 +12,7 @@ class DnsProvider(db.Model):
__tablename__ = "dns_providers" __tablename__ = "dns_providers"
id = Column(Integer(), primary_key=True) id = Column(Integer(), primary_key=True)
name = Column(String(length=256), unique=True, nullable=True) name = Column(String(length=256), unique=True, nullable=True)
description = Column(Text(), nullable=True) description = Column(String(length=1024), nullable=True)
provider_type = Column(String(length=256), nullable=True) provider_type = Column(String(length=256), nullable=True)
credentials = Column(Vault, nullable=True) credentials = Column(Vault, nullable=True)
api_endpoint = Column(String(length=256), nullable=True) api_endpoint = Column(String(length=256), nullable=True)

View File

@ -8,7 +8,7 @@ class DnsProvidersNestedOutputSchema(LemurOutputSchema):
__envelope__ = False __envelope__ = False
id = fields.Integer() id = fields.Integer()
name = fields.String() name = fields.String()
providerType = fields.String() provider_type = fields.String()
description = fields.String() description = fields.String()
credentials = fields.String() credentials = fields.String()
api_endpoint = fields.String() api_endpoint = fields.String()

View File

@ -95,7 +95,7 @@ LEMUR_TOKEN_SECRET = '{secret_token}'
LEMUR_ENCRYPTION_KEYS = '{encryption_key}' LEMUR_ENCRYPTION_KEYS = '{encryption_key}'
# List of domain regular expressions that non-admin users can issue # List of domain regular expressions that non-admin users can issue
LEMUR_WHITELISTED_DOMAINS = [] LEMUR_ALLOWED_DOMAINS = []
# Mail Server # Mail Server

View File

@ -20,8 +20,9 @@ fileConfig(config.config_file_name)
# target_metadata = mymodel.Base.metadata # target_metadata = mymodel.Base.metadata
from flask import current_app from flask import current_app
db_url_escaped = current_app.config.get('SQLALCHEMY_DATABASE_URI').replace('%', '%%')
config.set_main_option( config.set_main_option(
"sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") "sqlalchemy.url", db_url_escaped
) )
target_metadata = current_app.extensions["migrate"].db.metadata target_metadata = current_app.extensions["migrate"].db.metadata
@ -67,7 +68,8 @@ def run_migrations_online():
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
**current_app.extensions["migrate"].configure_args **current_app.extensions["migrate"].configure_args,
compare_type=True
) )
try: try:

View File

@ -0,0 +1,26 @@
"""empty message
Revision ID: 434c29e40511
Revises: 8323a5ea723a
Create Date: 2020-09-11 17:24:51.344585
"""
# revision identifiers, used by Alembic.
revision = '434c29e40511'
down_revision = '8323a5ea723a'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('certificates', sa.Column('key_type', sa.String(length=128), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('certificates', 'key_type')
# ### end Alembic commands ###

View File

@ -0,0 +1,114 @@
"""
This database upgrade updates the key_type information for either
still valid or expired certificates in the last 30 days. For RSA
keys, the algorithm is determined based on the key length. For
the rest of the keys, the certificate body is parsed to determine
the exact key_type information.
Each individual DB change is explicitly committed, and the respective
log is added to a file named db_upgrade.log in the current working
directory. Any error encountered while parsing a certificate will
also be logged along with the certificate ID. If faced with any issue
while running this upgrade, there is no harm in re-running the upgrade.
Each run processes only rows for which key_type information is not yet
determined.
A successful complete run will end up updating the Alembic Version to
the new Revision ID c301c59688d2. Currently, Lemur supports only RSA
and ECC certificates. This could be a long-running job depending upon
the number of DB entries it may process.
Revision ID: c301c59688d2
Revises: 434c29e40511
Create Date: 2020-09-21 14:28:50.757998
"""
# revision identifiers, used by Alembic.
revision = 'c301c59688d2'
down_revision = '434c29e40511'
from alembic import op
from sqlalchemy.sql import text
from lemur.common import utils
import time
import datetime
log_file = open('db_upgrade.log', 'a')
def upgrade():
log_file.write("\n*** Starting new run(%s) ***\n" % datetime.datetime.now())
start_time = time.time()
# Update RSA keys using the key length information
update_key_type_rsa(1024)
update_key_type_rsa(2048)
update_key_type_rsa(4096)
# Process remaining certificates. Though below method does not make any assumptions, most of the remaining ones should be ECC certs.
update_key_type()
log_file.write("--- Total %s seconds ---\n" % (time.time() - start_time))
log_file.close()
def downgrade():
# Change key type column back to null
# Going back 32 days instead of 31 to make sure no certificates are skipped
stmt = text(
"update certificates set key_type=null where not_after > CURRENT_DATE - 32"
)
op.execute(stmt)
"""
Helper methods performing updates for RSA and rest of the keys
"""
def update_key_type_rsa(bits):
log_file.write("Processing certificate with key type RSA %s\n" % bits)
stmt = text(
f"update certificates set key_type='RSA{bits}' where bits={bits} and not_after > CURRENT_DATE - 31 and key_type is null"
)
log_file.write("Query: %s\n" % stmt)
start_time = time.time()
op.execute(stmt)
commit()
log_file.write("--- %s seconds ---\n" % (time.time() - start_time))
def update_key_type():
conn = op.get_bind()
start_time = time.time()
# Loop through all certificates that are valid today or expired in the last 30 days.
for cert_id, body in conn.execute(
text(
"select id, body from certificates where not_after > CURRENT_DATE - 31 and key_type is null")
):
try:
cert_key_type = utils.get_key_type_from_certificate(body)
except ValueError as e:
log_file.write("Error in processing certificate - ID: %s Error: %s \n" % (cert_id, str(e)))
else:
log_file.write("Processing certificate - ID: %s key_type: %s\n" % (cert_id, cert_key_type))
stmt = text(
"update certificates set key_type=:key_type where id=:id"
)
stmt = stmt.bindparams(key_type=cert_key_type, id=cert_id)
op.execute(stmt)
commit()
log_file.write("--- %s seconds ---\n" % (time.time() - start_time))
def commit():
stmt = text("commit")
op.execute(stmt)

View File

@ -8,6 +8,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import sys
from collections import defaultdict from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from itertools import groupby from itertools import groupby
@ -29,7 +30,7 @@ from lemur.plugins.utils import get_plugin_option
def get_certificates(exclude=None): def get_certificates(exclude=None):
""" """
Finds all certificates that are eligible for notifications. Finds all certificates that are eligible for expiration notifications.
:param exclude: :param exclude:
:return: :return:
""" """
@ -41,6 +42,7 @@ def get_certificates(exclude=None):
.filter(Certificate.not_after <= max) .filter(Certificate.not_after <= max)
.filter(Certificate.notify == True) .filter(Certificate.notify == True)
.filter(Certificate.expired == False) .filter(Certificate.expired == False)
.filter(Certificate.revoked == False)
) # noqa ) # noqa
exclude_conditions = [] exclude_conditions = []
@ -61,7 +63,8 @@ def get_certificates(exclude=None):
def get_eligible_certificates(exclude=None): 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: :param exclude:
:return: :return:
""" """
@ -86,21 +89,31 @@ def get_eligible_certificates(exclude=None):
return 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. Executes the plugin and handles failure.
:param event_type: :param event_type:
:param data: :param data:
:param targets: :param recipients:
:param notification: :param notification:
:return: :return:
""" """
function = f"{__name__}.{sys._getframe().f_code.co_name}"
log_data = {
"function": function,
"message": f"Sending expiration notification for to recipients {recipients}",
"notification_type": "expiration",
"certificate_targets": recipients,
}
status = FAILURE_METRIC_STATUS status = FAILURE_METRIC_STATUS
try: try:
notification.plugin.send(event_type, data, targets, notification.options) current_app.logger.debug(log_data)
notification.plugin.send(event_type, data, recipients, notification.options)
status = SUCCESS_METRIC_STATUS status = SUCCESS_METRIC_STATUS
except Exception as e: except Exception as e:
log_data["message"] = f"Unable to send {event_type} notification to recipients {recipients}"
current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
metrics.send( metrics.send(
@ -140,36 +153,27 @@ def send_expiration_notifications(exclude):
notification_data.append(cert_data) notification_data.append(cert_data)
security_data.append(cert_data) security_data.append(cert_data)
if send_notification( if send_default_notification(
"expiration", notification_data, [owner], notification "expiration", notification_data, [owner], notification.options
): ):
success += 1 success += 1
else: else:
failure += 1 failure += 1
notification_recipient = get_plugin_option( recipients = notification.plugin.filter_recipients(notification.options, security_email + [owner])
"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]
if ( if send_plugin_notification(
notification_recipient "expiration",
notification_data,
recipients,
notification,
): ):
if send_notification( success += 1
"expiration", else:
notification_data, failure += 1
notification_recipient,
notification,
):
success += 1
else:
failure += 1
if send_notification( if send_default_notification(
"expiration", security_data, security_email, notification "expiration", security_data, security_email, notification.options
): ):
success += 1 success += 1
else: else:
@ -178,107 +182,86 @@ def send_expiration_notifications(exclude):
return success, failure 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 Sends a report to the specified target via the default notification plugin. Applicable for any notification_type.
rotated. At present, "default" means email, as the other notification plugins do not support dynamically configured targets.
:param certificate: :param notification_type:
:param notification_plugin: :param data:
:param targets:
:param notification_options:
:return: :return:
""" """
function = f"{__name__}.{sys._getframe().f_code.co_name}"
log_data = {
"function": function,
"message": f"Sending notification for certificate data {data}",
"notification_type": notification_type,
}
status = FAILURE_METRIC_STATUS status = FAILURE_METRIC_STATUS
if not notification_plugin: notification_plugin = plugins.get(
notification_plugin = plugins.get( current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN") )
)
data = certificate_notification_output_schema.dump(certificate).data
try: try:
notification_plugin.send("rotation", data, [data["owner"]]) 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 status = SUCCESS_METRIC_STATUS
except Exception as e: except Exception as e:
current_app.logger.error( log_data["message"] = f"Unable to send {notification_type} notification for certificate data {data} " \
"Unable to send notification to {}.".format(data["owner"]), exc_info=True f"to target {targets}"
) current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
metrics.send( metrics.send(
"notification", "notification",
"counter", "counter",
1, 1,
metric_tags={"status": status, "event_type": "rotation"}, metric_tags={"status": status, "event_type": notification_type},
) )
if status == SUCCESS_METRIC_STATUS: if status == SUCCESS_METRIC_STATUS:
return True 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( 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. Sends a report to certificate owners when their pending certificate failed to be created.
:param pending_cert: :param pending_cert:
:param notification_plugin: :param notify_owner:
:param notify_security:
:return: :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 = pending_certificate_output_schema.dump(pending_cert).data
data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL") data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL")
notify_owner_success = False
if notify_owner: if notify_owner:
try: notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert)
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_security_success = False
if notify_security: if notify_security:
try: notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert)
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()
metrics.send( return notify_owner_success or notify_security_success
"notification",
"counter",
1,
metric_tags={"status": status, "event_type": "rotation"},
)
if status == SUCCESS_METRIC_STATUS:
return True
def needs_notification(certificate): def needs_notification(certificate):
""" """
Determine if notifications for a given certificate should Determine if notifications for a given certificate should currently be sent.
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: :param certificate:
:return: :return:
@ -290,7 +273,7 @@ def needs_notification(certificate):
for notification in certificate.notifications: for notification in certificate.notifications:
if not notification.active or not notification.options: if not notification.active or not notification.options:
return continue
interval = get_plugin_option("interval", notification.options) interval = get_plugin_option("interval", notification.options)
unit = get_plugin_option("unit", notification.options) unit = get_plugin_option("unit", notification.options)
@ -306,9 +289,8 @@ def needs_notification(certificate):
else: else:
raise Exception( raise Exception(
"Invalid base unit for expiration interval: {0}".format(unit) f"Invalid base unit for expiration interval: {unit}"
) )
if days == interval: if days == interval:
notifications.append(notification) notifications.append(notification)
return notifications return notifications

View File

@ -20,6 +20,15 @@ class NotificationPlugin(Plugin):
def send(self, notification_type, message, targets, options, **kwargs): def send(self, notification_type, message, targets, options, **kwargs):
raise NotImplementedError 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): class ExpirationNotificationPlugin(NotificationPlugin):
""" """
@ -50,5 +59,5 @@ class ExpirationNotificationPlugin(NotificationPlugin):
def options(self): def options(self):
return self.default_options + self.additional_options 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 raise NotImplementedError

View File

@ -32,6 +32,7 @@ from lemur.extensions import metrics, sentry
from lemur.plugins import lemur_acme as acme from lemur.plugins import lemur_acme as acme
from lemur.plugins.bases import IssuerPlugin from lemur.plugins.bases import IssuerPlugin
from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns
from lemur.authorities import service as authorities_service
from retrying import retry from retrying import retry
@ -240,6 +241,7 @@ class AcmeHandler(object):
existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR")) existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR"))
if existing_key and existing_regr: if existing_key and existing_regr:
current_app.logger.debug("Reusing existing ACME account")
# Reuse the same account for each certificate issuance # Reuse the same account for each certificate issuance
key = jose.JWK.json_loads(existing_key) key = jose.JWK.json_loads(existing_key)
regr = messages.RegistrationResource.json_loads(existing_regr) regr = messages.RegistrationResource.json_loads(existing_regr)
@ -253,6 +255,7 @@ class AcmeHandler(object):
# Create an account for each certificate issuance # Create an account for each certificate issuance
key = jose.JWKRSA(key=generate_private_key("RSA2048")) key = jose.JWKRSA(key=generate_private_key("RSA2048"))
current_app.logger.debug("Creating a new ACME account")
current_app.logger.debug( current_app.logger.debug(
"Connecting with directory at {0}".format(directory_url) "Connecting with directory at {0}".format(directory_url)
) )
@ -262,6 +265,27 @@ class AcmeHandler(object):
registration = client.new_account_and_tos( registration = client.new_account_and_tos(
messages.NewRegistration.from_data(email=email) messages.NewRegistration.from_data(email=email)
) )
# if store_account is checked, add the private_key and registration resources to the options
if options['store_account']:
new_options = json.loads(authority.options)
# the key returned by fields_to_partial_json is missing the key type, so we add it manually
key_dict = key.fields_to_partial_json()
key_dict["kty"] = "RSA"
acme_private_key = {
"name": "acme_private_key",
"value": json.dumps(key_dict)
}
new_options.append(acme_private_key)
acme_regr = {
"name": "acme_regr",
"value": json.dumps({"body": {}, "uri": registration.uri})
}
new_options.append(acme_regr)
authorities_service.update_options(authority.id, options=json.dumps(new_options))
current_app.logger.debug("Connected: {0}".format(registration.uri)) current_app.logger.debug("Connected: {0}".format(registration.uri))
return client, registration return client, registration
@ -467,6 +491,13 @@ class ACMEIssuerPlugin(IssuerPlugin):
"validation": "/^-----BEGIN CERTIFICATE-----/", "validation": "/^-----BEGIN CERTIFICATE-----/",
"helpMessage": "Certificate to use", "helpMessage": "Certificate to use",
}, },
{
"name": "store_account",
"type": "bool",
"required": False,
"helpMessage": "Disable to create a new account for each ACME request",
"default": False,
}
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -1,8 +1,10 @@
import unittest import unittest
from unittest.mock import patch, Mock from unittest.mock import patch, Mock
import josepy as jose
from cryptography.x509 import DNSName from cryptography.x509 import DNSName
from lemur.plugins.lemur_acme import plugin from lemur.plugins.lemur_acme import plugin
from lemur.common.utils import generate_private_key
from mock import MagicMock from mock import MagicMock
@ -165,11 +167,65 @@ class TestAcme(unittest.TestCase):
with self.assertRaises(Exception): with self.assertRaises(Exception):
self.acme.setup_acme_client(mock_authority) self.acme.setup_acme_client(mock_authority)
@patch("lemur.plugins.lemur_acme.plugin.jose.JWK.json_loads")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app") @patch("lemur.plugins.lemur_acme.plugin.current_app")
def test_setup_acme_client_success(self, mock_current_app, mock_acme): def test_setup_acme_client_success_load_account_from_authority(self, mock_current_app, mock_acme, mock_key_json_load):
mock_authority = Mock() mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]' mock_authority.id = 2
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
'{"name": "store_account", "value": true},' \
'{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, ' \
'{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]'
mock_client = Mock()
mock_acme.return_value = mock_client
mock_current_app.config = {}
mock_key_json_load.return_value = jose.JWKRSA(key=generate_private_key("RSA2048"))
result_client, result_registration = self.acme.setup_acme_client(mock_authority)
mock_acme.new_account_and_tos.assert_not_called()
assert result_client
assert not result_registration
@patch("lemur.plugins.lemur_acme.plugin.jose.JWKRSA.fields_to_partial_json")
@patch("lemur.plugins.lemur_acme.plugin.authorities_service")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
def test_setup_acme_client_success_store_new_account(self, mock_current_app, mock_acme, mock_authorities_service,
mock_key_generation):
mock_authority = Mock()
mock_authority.id = 2
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
'{"name": "store_account", "value": true}]'
mock_client = Mock()
mock_registration = Mock()
mock_registration.uri = "http://test.com"
mock_client.register = mock_registration
mock_client.agree_to_tos = Mock(return_value=True)
mock_client.new_account_and_tos.return_value = mock_registration
mock_acme.return_value = mock_client
mock_current_app.config = {}
mock_key_generation.return_value = {"n": "PwIOkViO"}
mock_authorities_service.update_options = Mock(return_value=True)
self.acme.setup_acme_client(mock_authority)
mock_authorities_service.update_options.assert_called_with(2, options='[{"name": "mock_name", "value": "mock_value"}, '
'{"name": "store_account", "value": true}, '
'{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, '
'{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]')
@patch("lemur.plugins.lemur_acme.plugin.authorities_service")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
def test_setup_acme_client_success(self, mock_current_app, mock_acme, mock_authorities_service):
mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
'{"name": "store_account", "value": false}]'
mock_client = Mock() mock_client = Mock()
mock_registration = Mock() mock_registration = Mock()
mock_registration.uri = "http://test.com" mock_registration.uri = "http://test.com"
@ -178,6 +234,7 @@ class TestAcme(unittest.TestCase):
mock_acme.return_value = mock_client mock_acme.return_value = mock_client
mock_current_app.config = {} mock_current_app.config = {}
result_client, result_registration = self.acme.setup_acme_client(mock_authority) result_client, result_registration = self.acme.setup_acme_client(mock_authority)
mock_authorities_service.update_options.assert_not_called()
assert result_client assert result_client
assert result_registration assert result_registration

View File

@ -32,13 +32,14 @@
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com> .. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Harm Weites <harm@weites.com> .. moduleauthor:: Harm Weites <harm@weites.com>
""" """
from acme.errors import ClientError from acme.errors import ClientError
from flask import current_app 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.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): def get_region_from_dns(dns):
@ -406,3 +407,51 @@ class S3DestinationPlugin(ExportDestinationPlugin):
self.get_option("encrypt", options), self.get_option("encrypt", options),
account_number=self.get_option("accountNumber", 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 <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, region_name=self.get_option("region", options))

View File

@ -0,0 +1,55 @@
"""
.. 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:: Jasmine Schladen <jschladen@netflix.com>
"""
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)
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(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"]
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("YYYY-MM-ddTHH:mm:ss"), # 2047-12-T22:00:00
"endpoints_detected": len(certificate["endpoints"]),
"details": create_certificate_url(certificate["name"])
}
return json.dumps(json_message)

View File

@ -0,0 +1,120 @@
import json
from datetime import timedelta
import arrow
import boto3
from moto import mock_sns, mock_sqs, mock_ses
from lemur.certificates.schemas import certificate_notification_output_schema
from lemur.plugins.lemur_aws.sns import format_message
from lemur.plugins.lemur_aws.sns import publish
from lemur.tests.factories import NotificationFactory, CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
@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("YYYY-MM-ddTHH:mm:ss"),
"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 create_and_subscribe_to_topic():
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)
return [topic_arn, sqs_client, queue_url]
@mock_sns()
@mock_sqs()
def test_publish(certificate, endpoint):
data = [certificate_notification_output_schema.dump(certificate).data]
topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic()
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"]
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")
def get_options():
return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "region", "value": "us-east-1"},
{"name": "accountNumber", "value": "123456789012"},
{"name": "topicName", "value": "lemursnstest"},
]
@mock_sns()
@mock_sqs()
@mock_ses() # because email notifications are also sent
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email() # emails are sent to owner and security; SNS only used for configured notification
topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic()
notification = NotificationFactory(plugin_name="aws-sns")
notification.options = get_options()
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
assert send_expiration_notifications([]) == (3, 0) # owner, SNS, and security
received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"]
assert len(received_messages) == 1
expected_message = format_message(certificate_notification_output_schema.dump(certificate).data, "expiration")
actual_message = json.loads(received_messages[0]["Body"])["Message"]
assert actual_message == expected_message
# Currently disabled as the SNS plugin doesn't support this type of notification
# def test_send_rotation_notification(endpoint, source_plugin):
# from lemur.notifications.messaging import send_rotation_notification
# from lemur.deployment.service import rotate_certificate
#
# notification = NotificationFactory(plugin_name="aws-sns")
# notification.options = get_options()
#
# new_certificate = CertificateFactory()
# rotate_certificate(endpoint, new_certificate)
# assert endpoint.certificate == new_certificate
#
# assert send_rotation_notification(new_certificate)
# Currently disabled as the SNS plugin doesn't support this type of notification
# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
# from lemur.notifications.messaging import send_pending_failure_notification
#
# assert send_pending_failure_notification(pending_certificate)

View File

@ -18,9 +18,10 @@ import json
import arrow import arrow
import pem import pem
import requests import requests
import sys
from cryptography import x509 from cryptography import x509
from flask import current_app from flask import current_app, g
from lemur.common.utils import validate_conf from lemur.common.utils import validate_conf, convert_pkcs7_bytes_to_pem
from lemur.extensions import metrics from lemur.extensions import metrics
from lemur.plugins import lemur_digicert as digicert from lemur.plugins import lemur_digicert as digicert
from lemur.plugins.bases import IssuerPlugin, SourcePlugin from lemur.plugins.bases import IssuerPlugin, SourcePlugin
@ -36,7 +37,13 @@ def log_status_code(r, *args, **kwargs):
:param kwargs: :param kwargs:
:return: :return:
""" """
log_data = {
"reason": (r.reason if r.reason else ""),
"status_code": r.status_code,
"url": (r.url if r.url else ""),
}
metrics.send("digicert_status_code_{}".format(r.status_code), "counter", 1) metrics.send("digicert_status_code_{}".format(r.status_code), "counter", 1)
current_app.logger.info(log_data)
def signature_hash(signing_algorithm): def signature_hash(signing_algorithm):
@ -129,6 +136,9 @@ def map_fields(options, csr):
data["validity_years"] = determine_validity_years(options.get("validity_years")) data["validity_years"] = determine_validity_years(options.get("validity_years"))
elif options.get("validity_end"): elif options.get("validity_end"):
data["custom_expiration_date"] = determine_end_date(options.get("validity_end")).format("YYYY-MM-DD") data["custom_expiration_date"] = determine_end_date(options.get("validity_end")).format("YYYY-MM-DD")
# check if validity got truncated. If resultant validity is not equal to requested validity, it just got truncated
if data["custom_expiration_date"] != options.get("validity_end").format("YYYY-MM-DD"):
log_validity_truncation(options, f"{__name__}.{sys._getframe().f_code.co_name}")
else: else:
data["validity_years"] = determine_validity_years(0) data["validity_years"] = determine_validity_years(0)
@ -154,6 +164,9 @@ def map_cis_fields(options, csr):
validity_end = determine_end_date(arrow.utcnow().shift(years=options["validity_years"])) validity_end = determine_end_date(arrow.utcnow().shift(years=options["validity_years"]))
elif options.get("validity_end"): elif options.get("validity_end"):
validity_end = determine_end_date(options.get("validity_end")) validity_end = determine_end_date(options.get("validity_end"))
# check if validity got truncated. If resultant validity is not equal to requested validity, it just got truncated
if validity_end != options.get("validity_end"):
log_validity_truncation(options, f"{__name__}.{sys._getframe().f_code.co_name}")
else: else:
validity_end = determine_end_date(False) validity_end = determine_end_date(False)
@ -164,11 +177,10 @@ def map_cis_fields(options, csr):
"csr": csr, "csr": csr,
"signature_hash": signature_hash(options.get("signing_algorithm")), "signature_hash": signature_hash(options.get("signing_algorithm")),
"validity": { "validity": {
"valid_to": validity_end.format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": validity_end.format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"organization": { "organization": {
"name": options["organization"], "name": options["organization"],
"units": [options["organizational_unit"]],
}, },
} }
# possibility to default to a SIGNING_ALGORITHM for a given profile # possibility to default to a SIGNING_ALGORITHM for a given profile
@ -179,6 +191,18 @@ def map_cis_fields(options, csr):
return data return data
def log_validity_truncation(options, function):
log_data = {
"cn": options["common_name"],
"creator": g.user.username
}
metrics.send("digicert_validity_truncated", "counter", 1, metric_tags=log_data)
log_data["function"] = function
log_data["message"] = "Digicert Plugin truncated the validity of certificate"
current_app.logger.info(log_data)
def handle_response(response): def handle_response(response):
""" """
Handle the DigiCert API response and any errors it might have experienced. Handle the DigiCert API response and any errors it might have experienced.
@ -186,7 +210,7 @@ def handle_response(response):
:return: :return:
""" """
if response.status_code > 399: if response.status_code > 399:
raise Exception(response.json()["errors"][0]["message"]) raise Exception("DigiCert rejected request with the error:" + response.json()["errors"][0]["message"])
return response.json() return response.json()
@ -197,10 +221,17 @@ def handle_cis_response(response):
:param response: :param response:
:return: :return:
""" """
if response.status_code > 399: if response.status_code == 404:
raise Exception(response.text) raise Exception("DigiCert: order not in issued state")
elif response.status_code == 406:
raise Exception("DigiCert: wrong header request format")
elif response.status_code > 399:
raise Exception("DigiCert rejected request with the error:" + response.text)
return response.json() if response.url.endswith("download"):
return response.content
else:
return response.json()
@retry(stop_max_attempt_number=10, wait_fixed=10000) @retry(stop_max_attempt_number=10, wait_fixed=10000)
@ -216,15 +247,16 @@ def get_certificate_id(session, base_url, order_id):
@retry(stop_max_attempt_number=10, wait_fixed=10000) @retry(stop_max_attempt_number=10, wait_fixed=10000)
def get_cis_certificate(session, base_url, order_id): def get_cis_certificate(session, base_url, order_id):
"""Retrieve certificate order id from Digicert API.""" """Retrieve certificate order id from Digicert API, including the chain"""
certificate_url = "{0}/platform/cis/certificate/{1}".format(base_url, order_id) certificate_url = "{0}/platform/cis/certificate/{1}/download".format(base_url, order_id)
session.headers.update({"Accept": "application/x-pem-file"}) session.headers.update({"Accept": "application/x-pkcs7-certificates"})
response = session.get(certificate_url) response = session.get(certificate_url)
response_content = handle_cis_response(response)
if response.status_code == 404: cert_chain_pem = convert_pkcs7_bytes_to_pem(response_content)
raise Exception("Order not in issued state.") if len(cert_chain_pem) < 3:
raise Exception("Missing the certificate chain")
return response.content return cert_chain_pem
class DigiCertSourcePlugin(SourcePlugin): class DigiCertSourcePlugin(SourcePlugin):
@ -428,7 +460,6 @@ class DigiCertCISSourcePlugin(SourcePlugin):
"DIGICERT_CIS_API_KEY", "DIGICERT_CIS_API_KEY",
"DIGICERT_CIS_URL", "DIGICERT_CIS_URL",
"DIGICERT_CIS_ROOTS", "DIGICERT_CIS_ROOTS",
"DIGICERT_CIS_INTERMEDIATES",
"DIGICERT_CIS_PROFILE_NAMES", "DIGICERT_CIS_PROFILE_NAMES",
] ]
validate_conf(current_app, required_vars) validate_conf(current_app, required_vars)
@ -503,7 +534,6 @@ class DigiCertCISIssuerPlugin(IssuerPlugin):
"DIGICERT_CIS_API_KEY", "DIGICERT_CIS_API_KEY",
"DIGICERT_CIS_URL", "DIGICERT_CIS_URL",
"DIGICERT_CIS_ROOTS", "DIGICERT_CIS_ROOTS",
"DIGICERT_CIS_INTERMEDIATES",
"DIGICERT_CIS_PROFILE_NAMES", "DIGICERT_CIS_PROFILE_NAMES",
] ]
@ -533,22 +563,15 @@ class DigiCertCISIssuerPlugin(IssuerPlugin):
data = handle_cis_response(response) data = handle_cis_response(response)
# retrieve certificate # retrieve certificate
certificate_pem = get_cis_certificate(self.session, base_url, data["id"]) certificate_chain_pem = get_cis_certificate(self.session, base_url, data["id"])
self.session.headers.pop("Accept") self.session.headers.pop("Accept")
end_entity = pem.parse(certificate_pem)[0] end_entity = certificate_chain_pem[0]
intermediate = certificate_chain_pem[1]
if "ECC" in issuer_options["key_type"]:
return (
"\n".join(str(end_entity).splitlines()),
current_app.config.get("DIGICERT_ECC_CIS_INTERMEDIATES", {}).get(issuer_options['authority'].name),
data["id"],
)
# By default return RSA
return ( return (
"\n".join(str(end_entity).splitlines()), "\n".join(str(end_entity).splitlines()),
current_app.config.get("DIGICERT_CIS_INTERMEDIATES", {}).get(issuer_options['authority'].name), "\n".join(str(intermediate).splitlines()),
data["id"], data["id"],
) )

View File

@ -121,9 +121,9 @@ def test_map_cis_fields_with_validity_years(mock_current_app, authority):
"csr": CSR_STR, "csr": CSR_STR,
"additional_dns_names": names, "additional_dns_names": names,
"signature_hash": "sha256", "signature_hash": "sha256",
"organization": {"name": "Example, Inc.", "units": ["Example Org"]}, "organization": {"name": "Example, Inc."},
"validity": { "validity": {
"valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"profile_name": None, "profile_name": None,
} }
@ -157,9 +157,9 @@ def test_map_cis_fields_with_validity_end_and_start(mock_current_app, app, autho
"csr": CSR_STR, "csr": CSR_STR,
"additional_dns_names": names, "additional_dns_names": names,
"signature_hash": "sha256", "signature_hash": "sha256",
"organization": {"name": "Example, Inc.", "units": ["Example Org"]}, "organization": {"name": "Example, Inc."},
"validity": { "validity": {
"valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"profile_name": None, "profile_name": None,
} }

View File

@ -17,16 +17,19 @@ from lemur.plugins.bases import ExpirationNotificationPlugin
from lemur.plugins import lemur_email as email from lemur.plugins import lemur_email as email
from lemur.plugins.lemur_email.templates.config import env from lemur.plugins.lemur_email.templates.config import env
from lemur.plugins.utils import get_plugin_option
def render_html(template_name, message): def render_html(template_name, options, certificates):
""" """
Renders the html for our email notification. Renders the html for our email notification.
:param template_name: :param template_name:
:param message: :param options:
:param certificates:
:return: :return:
""" """
message = {"options": options, "certificates": certificates}
template = env.get_template("{}.html".format(template_name)) template = env.get_template("{}.html".format(template_name))
return template.render( return template.render(
dict(message=message, hostname=current_app.config.get("LEMUR_HOSTNAME")) dict(message=message, hostname=current_app.config.get("LEMUR_HOSTNAME"))
@ -100,8 +103,7 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
subject = "Lemur: {0} Notification".format(notification_type.capitalize()) subject = "Lemur: {0} Notification".format(notification_type.capitalize())
data = {"options": options, "certificates": message} body = render_html(notification_type, options, message)
body = render_html(notification_type, data)
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower() s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower()
@ -110,3 +112,13 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
elif s_type == "smtp": elif s_type == "smtp":
send_via_smtp(subject, body, targets) send_via_smtp(subject, body, targets)
@staticmethod
def filter_recipients(options, excluded_recipients, **kwargs):
notification_recipients = get_plugin_option("recipients", options)
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

View File

@ -83,12 +83,12 @@
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
<br>{{ certificate.owner }} <br>{{ message.certificates.owner }}
<br>{{ certificate.validityEnd | time }} <br>{{ message.certificates.validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ certificate.name }}" target="_blank">Details</a> <a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>
@ -110,12 +110,12 @@
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.replacedBy[0].name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
<br>{{ certificate.replacedBy[0].owner }} <br>{{ message.certificates.owner }}
<br>{{ certificate.replacedBy[0].validityEnd | time }} <br>{{ message.certificates.validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ certificate.replacedBy[0].name }}" target="_blank">Details</a> <a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>
@ -133,7 +133,7 @@
<table border="0" cellspacing="0" cellpadding="0" <table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px"> style="margin-top:48px;margin-bottom:48px">
<tbody> <tbody>
{% for endpoint in certificate.endpoints %} {% for endpoint in message.certificates.endpoints %}
<tr valign="middle"> <tr valign="middle">
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>

View File

@ -1,36 +1,90 @@
import os import os
from lemur.plugins.lemur_email.templates.config import env from datetime import timedelta
import arrow
from moto import mock_ses
from lemur.certificates.schemas import certificate_notification_output_schema
from lemur.plugins.lemur_email.plugin import render_html
from lemur.tests.factories import CertificateFactory from lemur.tests.factories import CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
dir_path = os.path.dirname(os.path.realpath(__file__)) dir_path = os.path.dirname(os.path.realpath(__file__))
def test_render(certificate, endpoint): def get_options():
from lemur.certificates.schemas import certificate_notification_output_schema return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "recipients", "value": "person1@example.com,person2@example.com"},
]
def test_render_expiration(certificate, endpoint):
new_cert = CertificateFactory() new_cert = CertificateFactory()
new_cert.replaces.append(certificate) new_cert.replaces.append(certificate)
data = { assert render_html("expiration", get_options(), [certificate_notification_output_schema.dump(certificate).data])
"certificates": [certificate_notification_output_schema.dump(certificate).data],
"options": [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
],
}
template = env.get_template("{}.html".format("expiration"))
body = template.render(dict(message=data, hostname="lemur.test.example.com"))
template = env.get_template("{}.html".format("rotation"))
def test_render_rotation(certificate, endpoint):
certificate.endpoints.append(endpoint) certificate.endpoints.append(endpoint)
body = template.render( assert render_html("rotation", get_options(), certificate_notification_output_schema.dump(certificate).data)
dict(
certificate=certificate_notification_output_schema.dump(certificate).data,
hostname="lemur.test.example.com", def test_render_rotation_failure(pending_certificate):
) assert render_html("failed", get_options(), certificate_notification_output_schema.dump(pending_certificate).data)
)
@mock_ses
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
from lemur.tests.factories import CertificateFactory
from lemur.tests.factories import NotificationFactory
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
notification = NotificationFactory(plugin_name="email-notification")
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
certificate.notifications[0].options = get_options()
verify_sender_email()
assert send_expiration_notifications([]) == (3, 0) # owner, recipients (only counted as 1), and security
@mock_ses
def test_send_rotation_notification(endpoint, source_plugin):
from lemur.notifications.messaging import send_rotation_notification
from lemur.deployment.service import rotate_certificate
new_certificate = CertificateFactory()
rotate_certificate(endpoint, new_certificate)
assert endpoint.certificate == new_certificate
verify_sender_email()
assert send_rotation_notification(new_certificate)
@mock_ses
def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
from lemur.notifications.messaging import send_pending_failure_notification
verify_sender_email()
assert send_pending_failure_notification(pending_certificate)
def test_filter_recipients(certificate, endpoint):
from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin
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"]) == []

View File

@ -0,0 +1,5 @@
"""Set the version information."""
try:
VERSION = __import__("pkg_resources").get_distribution(__name__).version
except Exception as e:
VERSION = "unknown"

View File

@ -0,0 +1,266 @@
import arrow
import requests
import json
import sys
from flask import current_app
from lemur.plugins import lemur_entrust as entrust
from lemur.plugins.bases import IssuerPlugin, SourcePlugin
from lemur.extensions import metrics
from lemur.common.utils import validate_conf
def log_status_code(r, *args, **kwargs):
"""
Is a request hook that logs all status codes to the ENTRUST api.
:param r:
:param args:
:param kwargs:
:return:
"""
log_data = {
"reason": (r.reason if r.reason else ""),
"status_code": r.status_code,
"url": (r.url if r.url else ""),
}
metrics.send(f"entrust_status_code_{r.status_code}", "counter", 1)
current_app.logger.info(log_data)
def determine_end_date(end_date):
"""
Determine appropriate end date
:param end_date:
:return: validity_end as string
"""
# ENTRUST only allows 13 months of max certificate duration
max_validity_end = arrow.utcnow().shift(years=1, months=+1)
if not end_date:
end_date = max_validity_end
elif end_date > max_validity_end:
end_date = max_validity_end
return end_date.format('YYYY-MM-DD')
def process_options(options):
"""
Processes and maps the incoming issuer options to fields/options that
Entrust understands
:param options:
:return: dict of valid entrust options
"""
# if there is a config variable ENTRUST_PRODUCT_<upper(authority.name)>
# take the value as Cert product-type
# else default to "STANDARD_SSL"
authority = options.get("authority").name.upper()
# STANDARD_SSL (cn=domain, san=www.domain),
# ADVANTAGE_SSL (cn=domain, san=[www.domain, one_more_option]),
# WILDCARD_SSL (unlimited sans, and wildcard)
product_type = current_app.config.get(f"ENTRUST_PRODUCT_{authority}", "STANDARD_SSL")
if options.get("validity_end"):
validity_end = determine_end_date(options.get("validity_end"))
else:
validity_end = determine_end_date(False)
tracking_data = {
"requesterName": current_app.config.get("ENTRUST_NAME"),
"requesterEmail": current_app.config.get("ENTRUST_EMAIL"),
"requesterPhone": current_app.config.get("ENTRUST_PHONE")
}
data = {
"signingAlg": "SHA-2",
"eku": "SERVER_AND_CLIENT_AUTH",
"certType": product_type,
"certExpiryDate": validity_end,
# "keyType": "RSA", Entrust complaining about this parameter
"tracking": tracking_data
}
return data
def handle_response(my_response):
"""
Helper function for parsing responses from the Entrust API.
:param content:
:return: :raise Exception:
"""
msg = {
200: "The request had the validateOnly flag set to true and validation was successful.",
201: "Certificate created",
202: "Request accepted and queued for approval",
400: "Invalid request parameters",
404: "Unknown jobId",
429: "Too many requests"
}
try:
d = json.loads(my_response.content)
except ValueError:
# catch an empty jason object here
d = {'response': 'No detailed message'}
s = my_response.status_code
if s > 399:
raise Exception(f"ENTRUST error: {msg.get(s, s)}\n{d['errors']}")
log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Response",
"status": s,
"response": d
}
current_app.logger.info(log_data)
if d == {'response': 'No detailed message'}:
# status if no data
return s
else:
# return data from the response
return d
class EntrustIssuerPlugin(IssuerPlugin):
title = "Entrust"
slug = "entrust-issuer"
description = "Enables the creation of certificates by ENTRUST"
version = entrust.VERSION
author = "sirferl"
author_url = "https://github.com/sirferl/lemur"
def __init__(self, *args, **kwargs):
"""Initialize the issuer with the appropriate details."""
required_vars = [
"ENTRUST_API_CERT",
"ENTRUST_API_KEY",
"ENTRUST_API_USER",
"ENTRUST_API_PASS",
"ENTRUST_URL",
"ENTRUST_ROOT",
"ENTRUST_NAME",
"ENTRUST_EMAIL",
"ENTRUST_PHONE",
]
validate_conf(current_app, required_vars)
self.session = requests.Session()
cert_file = current_app.config.get("ENTRUST_API_CERT")
key_file = current_app.config.get("ENTRUST_API_KEY")
user = current_app.config.get("ENTRUST_API_USER")
password = current_app.config.get("ENTRUST_API_PASS")
self.session.cert = (cert_file, key_file)
self.session.auth = (user, password)
self.session.hooks = dict(response=log_status_code)
# self.session.config['keep_alive'] = False
super(EntrustIssuerPlugin, self).__init__(*args, **kwargs)
def create_certificate(self, csr, issuer_options):
"""
Creates an Entrust certificate.
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Requesting options",
"options": issuer_options
}
current_app.logger.info(log_data)
url = current_app.config.get("ENTRUST_URL") + "/certificates"
data = process_options(issuer_options)
data["csr"] = csr
try:
response = self.session.post(url, json=data, timeout=(15, 40))
except requests.exceptions.Timeout:
raise Exception("Timeout for POST")
except requests.exceptions.RequestException as e:
raise Exception(f"Error for POST {e}")
response_dict = handle_response(response)
external_id = response_dict['trackingId']
cert = response_dict['endEntityCert']
if len(response_dict['chainCerts']) < 2:
# certificate signed by CA directly, no ICA included ini the chain
chain = None
else:
chain = response_dict['chainCerts'][1]
log_data["message"] = "Received Chain"
log_data["options"] = f"chain: {chain}"
current_app.logger.info(log_data)
return cert, chain, external_id
def revoke_certificate(self, certificate, comments):
"""Revoke an Entrust certificate."""
base_url = current_app.config.get("ENTRUST_URL")
# make certificate revoke request
revoke_url = f"{base_url}/certificates/{certificate.external_id}/revocations"
if not comments or comments == '':
comments = "revoked via API"
data = {
"crlReason": "superseded", # enum (keyCompromise, affiliationChanged, superseded, cessationOfOperation)
"revocationComment": comments
}
response = self.session.post(revoke_url, json=data)
metrics.send("entrust_revoke_certificate", "counter", 1)
return handle_response(response)
def deactivate_certificate(self, certificate):
"""Deactivates an Entrust certificate."""
base_url = current_app.config.get("ENTRUST_URL")
deactivate_url = f"{base_url}/certificates/{certificate.external_id}/deactivations"
response = self.session.post(deactivate_url)
metrics.send("entrust_deactivate_certificate", "counter", 1)
return handle_response(response)
@staticmethod
def create_authority(options):
"""Create an authority.
Creates an authority, this authority is then used by Lemur to
allow a user to specify which Certificate Authority they want
to sign their certificate.
:param options:
:return:
"""
entrust_root = current_app.config.get("ENTRUST_ROOT")
entrust_issuing = current_app.config.get("ENTRUST_ISSUING")
role = {"username": "", "password": "", "name": "entrust"}
current_app.logger.info(f"Creating Auth: {options} {entrust_issuing}")
# body, chain, role
return entrust_root, "", [role]
def get_ordered_certificate(self, order_id):
raise NotImplementedError("Not implemented\n", self, order_id)
def canceled_ordered_certificate(self, pending_cert, **kwargs):
raise NotImplementedError("Not implemented\n", self, pending_cert, **kwargs)
class EntrustSourcePlugin(SourcePlugin):
title = "Entrust"
slug = "entrust-source"
description = "Enables the collection of certificates"
version = entrust.VERSION
author = "sirferl"
author_url = "https://github.com/sirferl/lemur"
def get_certificates(self, options, **kwargs):
# Not needed for ENTRUST
raise NotImplementedError("Not implemented\n", self, options, **kwargs)
def get_endpoints(self, options, **kwargs):
# There are no endpoints in ENTRUST
raise NotImplementedError("Not implemented\n", self, options, **kwargs)

View File

@ -0,0 +1 @@
from lemur.tests.conftest import * # noqa

View File

@ -0,0 +1,62 @@
from unittest.mock import patch, Mock
import arrow
from cryptography import x509
from lemur.plugins.lemur_entrust import plugin
from freezegun import freeze_time
def config_mock(*args):
values = {
"ENTRUST_API_CERT": "-----BEGIN CERTIFICATE-----abc-----END CERTIFICATE-----",
"ENTRUST_API_KEY": False,
"ENTRUST_API_USER": "test",
"ENTRUST_API_PASS": "password",
"ENTRUST_URL": "http",
"ENTRUST_ROOT": None,
"ENTRUST_NAME": "test",
"ENTRUST_EMAIL": "test@lemur.net",
"ENTRUST_PHONE": "0123456",
"ENTRUST_PRODUCT_ENTRUST": "ADVANTAGE_SSL"
}
return values[args[0]]
@patch("lemur.plugins.lemur_digicert.plugin.current_app")
def test_determine_end_date(mock_current_app):
with freeze_time(time_to_freeze=arrow.get(2016, 11, 3).datetime):
assert arrow.get(2017, 12, 3).format('YYYY-MM-DD') == plugin.determine_end_date(0) # 1 year + 1 month
assert arrow.get(2017, 3, 5).format('YYYY-MM-DD') == plugin.determine_end_date(arrow.get(2017, 3, 5))
assert arrow.get(2017, 12, 3).format('YYYY-MM-DD') == plugin.determine_end_date(arrow.get(2020, 5, 7))
@patch("lemur.plugins.lemur_entrust.plugin.current_app")
def test_process_options(mock_current_app, authority):
mock_current_app.config.get = Mock(side_effect=config_mock)
plugin.determine_end_date = Mock(return_value=arrow.get(2017, 11, 5).format('YYYY-MM-DD'))
authority.name = "Entrust"
names = [u"one.example.com", u"two.example.com", u"three.example.com"]
options = {
"common_name": "example.com",
"owner": "bob@example.com",
"description": "test certificate",
"extensions": {"sub_alt_names": {"names": [x509.DNSName(x) for x in names]}},
"organization": "Example, Inc.",
"organizational_unit": "Example Org",
"validity_end": arrow.utcnow().shift(years=1, months=+1),
"authority": authority,
}
expected = {
"signingAlg": "SHA-2",
"eku": "SERVER_AND_CLIENT_AUTH",
"certType": "ADVANTAGE_SSL",
"certExpiryDate": arrow.get(2017, 11, 5).format('YYYY-MM-DD'),
"tracking": {
"requesterName": mock_current_app.config.get("ENTRUST_NAME"),
"requesterEmail": mock_current_app.config.get("ENTRUST_EMAIL"),
"requesterPhone": mock_current_app.config.get("ENTRUST_PHONE")
}
}
assert expected == plugin.process_options(options)

View File

@ -58,26 +58,19 @@ def create_rotation_attachments(certificate):
"title": certificate["name"], "title": certificate["name"],
"title_link": create_certificate_url(certificate["name"]), "title_link": create_certificate_url(certificate["name"]),
"fields": [ "fields": [
{"title": "Owner", "value": certificate["owner"], "short": True},
{ {
{"title": "Owner", "value": certificate["owner"], "short": True}, "title": "Expires",
{ "value": arrow.get(certificate["validityEnd"]).format(
"title": "Expires", "dddd, MMMM D, YYYY"
"value": arrow.get(certificate["validityEnd"]).format( ),
"dddd, MMMM D, YYYY" "short": True,
), },
"short": True, {
}, "title": "Endpoints Rotated",
{ "value": len(certificate["endpoints"]),
"title": "Replaced By", "short": True,
"value": len(certificate["replaced"][0]["name"]), },
"short": True,
},
{
"title": "Endpoints Rotated",
"value": len(certificate["endpoints"]),
"short": True,
},
}
], ],
} }
@ -119,6 +112,9 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
""" """
A typical check can be performed using the notify command: A typical check can be performed using the notify command:
`lemur notify` `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 attachments = None
if notification_type == "expiration": if notification_type == "expiration":
@ -131,7 +127,7 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
raise Exception("Unable to create message attachments") raise Exception("Unable to create message attachments")
body = { body = {
"text": "Lemur {0} Notification".format(notification_type.capitalize()), "text": f"Lemur {notification_type.capitalize()} Notification",
"attachments": attachments, "attachments": attachments,
"channel": self.get_option("recipients", options), "channel": self.get_option("recipients", options),
"username": self.get_option("username", options), "username": self.get_option("username", options),
@ -140,8 +136,8 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
r = requests.post(self.get_option("webhook", options), json.dumps(body)) r = requests.post(self.get_option("webhook", options), json.dumps(body))
if r.status_code not in [200]: 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.error( current_app.logger.info(
"Slack response: {0} Message Body: {1}".format(r.status_code, body) f"Slack response: {r.status_code} Message Body: {body}"
) )

View File

@ -1,3 +1,12 @@
from datetime import timedelta
import arrow
from moto import mock_ses
from lemur.tests.factories import NotificationFactory, CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
def test_formatting(certificate): def test_formatting(certificate):
from lemur.plugins.lemur_slack.plugin import create_expiration_attachments from lemur.plugins.lemur_slack.plugin import create_expiration_attachments
from lemur.certificates.schemas import certificate_notification_output_schema from lemur.certificates.schemas import certificate_notification_output_schema
@ -21,3 +30,52 @@ def test_formatting(certificate):
} }
assert attachment == create_expiration_attachments(data)[0] assert attachment == create_expiration_attachments(data)[0]
def get_options():
return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "webhook", "value": "https://slack.com/api/api.test"},
]
@mock_ses() # because email notifications are also sent
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email() # emails are sent to owner and security; Slack only used for configured notification
notification = NotificationFactory(plugin_name="slack-notification")
notification.options = get_options()
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
assert send_expiration_notifications([]) == (3, 0) # owner, Slack, and security
# Currently disabled as the Slack plugin doesn't support this type of notification
# def test_send_rotation_notification(endpoint, source_plugin):
# from lemur.notifications.messaging import send_rotation_notification
# from lemur.deployment.service import rotate_certificate
#
# notification = NotificationFactory(plugin_name="slack-notification")
# notification.options = get_options()
#
# new_certificate = CertificateFactory()
# rotate_certificate(endpoint, new_certificate)
# assert endpoint.certificate == new_certificate
#
# assert send_rotation_notification(new_certificate, notification_plugin=notification.plugin)
# Currently disabled as the Slack plugin doesn't support this type of notification
# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
# from lemur.notifications.messaging import send_pending_failure_notification
#
# assert send_pending_failure_notification(pending_certificate, notification_plugin=plugins.get("slack-notification"))

View File

@ -128,3 +128,11 @@ def render(args):
query = database.filter(query, Role, terms) query = database.filter(query, Role, terms)
return database.sort_and_page(query, Role, args) return database.sort_and_page(query, Role, args)
def get_or_create(role_name, description):
role = get_by_name(role_name)
if not role:
role = create(name=role_name, description=description)
return role

View File

@ -124,4 +124,8 @@ angular.module('lemur')
opened: false opened: false
}; };
$scope.populateSubjectEmail = function () {
$scope.authority.email = $scope.authority.owner;
};
}); });

View File

@ -26,8 +26,7 @@
Location Location
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<input name="location" ng-model="authority.location" placeholder="Location" class="form-control" required/> <input name="location" ng-model="authority.location" placeholder="Location" class="form-control"/>
<p ng-show="dnForm.location.$invalid && !dnForm.location.$pristine" class="help-block">You must enter a location</p>
</div> </div>
</div> </div>
<div class="form-group" <div class="form-group"
@ -49,6 +48,15 @@
<input name="organizationalUnit" ng-model="authority.organizationalUnit" placeholder="Organizational Unit" class="form-control"/> <input name="organizationalUnit" ng-model="authority.organizationalUnit" placeholder="Organizational Unit" class="form-control"/>
</div> </div>
</div> </div>
<div class="form-group"
ng-class="{'has-error': dnForm.email.$invalid, 'has-success': !dnForm.$invalid&&dnForm.email.$dirty}">
<label class="control-label col-sm-2">
Email
</label>
<div class="col-sm-10">
<input type="email" name="email" ng-model="authority.email" placeholder="Email Address" class="form-control"/>
</div>
</div>
</div> </div>
</form> </form>

View File

@ -4,7 +4,7 @@
Signing Algorithm Signing Algorithm
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" ng-model="authority.signingAlgorithm" ng-options="option for option in ['sha1WithRSA', 'sha256WithRSA']" ng-init="authority.signingAlgorithm = 'sha256WithRSA'"></select> <select class="form-control" ng-model="authority.signingAlgorithm" ng-options="option for option in ['sha1WithRSA', 'sha256WithRSA', 'sha256WithECDSA', 'SHA384withECDSA', 'SHA512withECDSA']" ng-init="authority.signingAlgorithm = 'sha256WithRSA'"></select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -20,8 +20,16 @@
Key Type Key Type
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" ng-model="authority.keyType" ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1', 'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1', <select class="form-control" ng-model="authority.keyType"
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1', 'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']" ng-init="authority.keyType = 'RSA2048'"></select> ng-options="option.value as option.name for option in [
{'name': 'RSA-2048', 'value': 'RSA2048'},
{'name': 'RSA-4096', 'value': 'RSA4096'},
{'name': 'ECC-PRIME256V1', 'value': 'ECCPRIME256V1'},
{'name': 'ECC-SECP384R1', 'value': 'ECCSECP384R1'},
{'name': 'ECC-SECP521R1', 'value': 'ECCSECP521R1'}]"
ng-init="authority.keyType = 'RSA2048'">
</select>
</div> </div>
</div> </div>
<div ng-show="authority.sensitivity == 'high'" class="form-group"> <div ng-show="authority.sensitivity == 'high'" class="form-group">

View File

@ -21,7 +21,7 @@
<div class="col-sm-10"> <div class="col-sm-10">
<input type="email" name="owner" ng-model="authority.owner" placeholder="TeamDL@example.com" <input type="email" name="owner" ng-model="authority.owner" placeholder="TeamDL@example.com"
uib-tooltip="This is the authorities team distribution list or the main point of contact for this authority" uib-tooltip="This is the authorities team distribution list or the main point of contact for this authority"
class="form-control" required/> class="form-control" ng-change="populateSubjectEmail()" required/>
<p ng-show="trackingForm.owner.$invalid && !trackingForm.owner.$pristine" class="help-block">You must <p ng-show="trackingForm.owner.$invalid && !trackingForm.owner.$pristine" class="help-block">You must
enter an Certificate Authority owner</p> enter an Certificate Authority owner</p>
</div> </div>

View File

@ -107,7 +107,6 @@ angular.module('lemur')
startingDay: 1 startingDay: 1
}; };
$scope.open1 = function() { $scope.open1 = function() {
$scope.popup1.opened = true; $scope.popup1.opened = true;
}; };
@ -140,6 +139,14 @@ angular.module('lemur')
); );
$scope.create = function (certificate) { $scope.create = function (certificate) {
if(certificate.validityType === 'customDates' &&
(!certificate.validityStart || !certificate.validityEnd)) { // these are not mandatory fields in schema, thus handling validation in js
return showMissingDateError();
}
if(certificate.validityType === 'defaultDays') {
populateValidityDateAsPerDefault(certificate);
}
WizardHandler.wizard().context.loading = true; WizardHandler.wizard().context.loading = true;
CertificateService.create(certificate).then( CertificateService.create(certificate).then(
function () { function () {
@ -164,6 +171,30 @@ angular.module('lemur')
}); });
}; };
function showMissingDateError() {
let error = {};
error.message = '';
error.reasons = {};
error.reasons.validityRange = 'Valid start and end dates are needed, else select Default option';
toaster.pop({
type: 'error',
title: 'Validation Error',
body: 'lemur-bad-request',
bodyOutputType: 'directive',
directiveData: error,
timeout: 100000
});
}
function populateValidityDateAsPerDefault(certificate) {
// calculate start and end date as per default validity
let startDate = new Date(), endDate = new Date();
endDate.setDate(startDate.getDate() + certificate.authority.defaultValidityDays);
certificate.validityStart = startDate;
certificate.validityEnd = endDate;
}
$scope.templates = [ $scope.templates = [
{ {
'name': 'Client Certificate', 'name': 'Client Certificate',
@ -220,10 +251,10 @@ angular.module('lemur')
$scope.certificate.csr = null; // should not clone CSR in case other settings are changed in clone $scope.certificate.csr = null; // should not clone CSR in case other settings are changed in clone
$scope.certificate.validityStart = null; $scope.certificate.validityStart = null;
$scope.certificate.validityEnd = null; $scope.certificate.validityEnd = null;
$scope.certificate.keyType = 'RSA2048'; // default algo to show during clone
$scope.certificate.description = 'Cloning from cert ID ' + editId; $scope.certificate.description = 'Cloning from cert ID ' + editId;
$scope.certificate.replacedBy = []; // should not clone 'replaced by' info $scope.certificate.replacedBy = []; // should not clone 'replaced by' info
$scope.certificate.removeReplaces(); // should not clone 'replacement cert' info $scope.certificate.removeReplaces(); // should not clone 'replacement cert' info
CertificateService.getDefaults($scope.certificate); CertificateService.getDefaults($scope.certificate);
}); });
@ -277,6 +308,14 @@ angular.module('lemur')
}; };
$scope.create = function (certificate) { $scope.create = function (certificate) {
if(certificate.validityType === 'customDates' &&
(!certificate.validityStart || !certificate.validityEnd)) { // these are not mandatory fields in schema, thus handling validation in js
return showMissingDateError();
}
if(certificate.validityType === 'defaultDays') {
populateValidityDateAsPerDefault(certificate);
}
WizardHandler.wizard().context.loading = true; WizardHandler.wizard().context.loading = true;
CertificateService.create(certificate).then( CertificateService.create(certificate).then(
function () { function () {
@ -301,6 +340,30 @@ angular.module('lemur')
}); });
}; };
function showMissingDateError() {
let error = {};
error.message = '';
error.reasons = {};
error.reasons.validityRange = 'Valid start and end dates are needed, else select Default option';
toaster.pop({
type: 'error',
title: 'Validation Error',
body: 'lemur-bad-request',
bodyOutputType: 'directive',
directiveData: error,
timeout: 100000
});
}
function populateValidityDateAsPerDefault(certificate) {
// calculate start and end date as per default validity
let startDate = new Date(), endDate = new Date();
endDate.setDate(startDate.getDate() + certificate.authority.defaultValidityDays);
certificate.validityStart = startDate;
certificate.validityEnd = endDate;
}
$scope.templates = [ $scope.templates = [
{ {
'name': 'Client Certificate', 'name': 'Client Certificate',

View File

@ -38,9 +38,7 @@
Location Location
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<input name="location" ng-model="certificate.location" placeholder="Location" class="form-control" required/> <input name="location" ng-model="certificate.location" placeholder="Location" class="form-control"/>
<p ng-show="dnForm.location.$invalid && !dnForm.location.$pristine" class="help-block">You must enter a
location</p>
</div> </div>
</div> </div>
<div class="form-group" <div class="form-group"

View File

@ -20,7 +20,7 @@
name="certificate signing request" name="certificate signing request"
ng-model="certificate.csr" ng-model="certificate.csr"
placeholder="PEM encoded string..." class="form-control" placeholder="PEM encoded string..." class="form-control"
ng-pattern="/^-----BEGIN CERTIFICATE REQUEST-----/"></textarea> ng-pattern="/(^-----BEGIN CERTIFICATE REQUEST-----[\S\s]*-----END CERTIFICATE REQUEST-----)|(^-----BEGIN NEW CERTIFICATE REQUEST-----[\S\s]*-----END NEW CERTIFICATE REQUEST-----)/"></textarea>
<p ng-show="trackingForm.csr.$invalid && !trackingForm.csr.$pristine" <p ng-show="trackingForm.csr.$invalid && !trackingForm.csr.$pristine"
class="help-block">Enter a valid certificate signing request.</p> class="help-block">Enter a valid certificate signing request.</p>
@ -32,10 +32,12 @@
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" ng-model="certificate.keyType" <select class="form-control" ng-model="certificate.keyType"
ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1', ng-options="option.value as option.name for option in [
'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1', {'name': 'RSA-2048', 'value': 'RSA2048'},
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1', {'name': 'RSA-4096', 'value': 'RSA4096'},
'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']" {'name': 'ECC-PRIME256V1', 'value': 'ECCPRIME256V1'},
{'name': 'ECC-SECP384R1', 'value': 'ECCSECP384R1'}]"
ng-init="certificate.keyType = 'RSA2048'"></select> ng-init="certificate.keyType = 'RSA2048'"></select>
</div> </div>
</div> </div>

View File

@ -96,7 +96,7 @@
Certificate Authority Certificate Authority
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<ui-select class="input-md" ng-model="certificate.authority" theme="bootstrap" title="choose an authority"> <ui-select class="input-md" ng-model="certificate.authority" theme="bootstrap" title="choose an authority" ng-change="clearDates()">
<ui-select-match placeholder="select an authority...">{{$select.selected.name}}</ui-select-match> <ui-select-match placeholder="select an authority...">{{$select.selected.name}}</ui-select-match>
<ui-select-choices class="form-control" repeat="authority in authorities" <ui-select-choices class="form-control" repeat="authority in authorities"
refresh="getAuthoritiesByName($select.search)" refresh="getAuthoritiesByName($select.search)"
@ -133,22 +133,20 @@
</div> </div>
<div class="form-group" ng-hide="certificate.authority.plugin.slug == 'acme-issuer'"> <div class="form-group" ng-hide="certificate.authority.plugin.slug == 'acme-issuer'">
<label class="control-label col-sm-2" <label class="control-label col-sm-2"
uib-tooltip="If no date is selected Lemur attempts to issue a 1 year certificate"> uib-tooltip="You can select custom date range; however, we recommend continuing with default validity.">
Validity Range <span class="glyphicon glyphicon-question-sign"></span> Validity Range <span class="glyphicon glyphicon-question-sign"></span>
</label> </label>
<div class="col-sm-2"> <div class="col-sm-4">
<select ng-model="certificate.validityYears" class="form-control"> <div class="btn-group btn-group-toggle" data-toggle="buttons">
<option value="">-</option> <label class="btn btn-info" ng-model="certificate.validityType" uib-btn-radio="'defaultDays'" ng-click="clearDates()">
<option value="1">1 year</option> Default ({{certificate.authority.defaultValidityDays}} days)</label>
</select> <label class="btn btn-info" ng-model="certificate.validityType" uib-btn-radio="'customDates'" ng-change="clearDates()">Custom</label>
</div>
</div> </div>
<span style="padding-top: 15px" class="text-center col-sm-1"> <div class="col-sm-3" ng-if="certificate.validityType==='customDates'">
<strong>or</strong>
</span>
<div class="col-sm-3">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" <input type="text" class="form-control"
uib-tooltip="yyyy/MM/dd" uib-tooltip="Start Date (yyyy/MM/dd)"
uib-datepicker-popup="yyyy/MM/dd" uib-datepicker-popup="yyyy/MM/dd"
ng-model="certificate.validityStart" ng-model="certificate.validityStart"
ng-change="certificate.setValidityEndDateRange(certificate.validityStart)" ng-change="certificate.setValidityEndDateRange(certificate.validityStart)"
@ -159,6 +157,7 @@
min-date="certificate.authority.authorityCertificate.notBefore" min-date="certificate.authority.authorityCertificate.notBefore"
alt-input-formats="altInputFormats" alt-input-formats="altInputFormats"
placeholder="Start Date" placeholder="Start Date"
readonly="true"
/> />
<span class="input-group-btn"> <span class="input-group-btn">
<button type="button" class="btn btn-default" ng-click="open1()"><i <button type="button" class="btn btn-default" ng-click="open1()"><i
@ -166,10 +165,10 @@
</span> </span>
</div> </div>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3" ng-if="certificate.validityType==='customDates'">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" <input type="text" class="form-control"
uib-tooltip="yyyy/MM/dd" uib-tooltip="End Date (yyyy/MM/dd)"
uib-datepicker-popup="yyyy/MM/dd" uib-datepicker-popup="yyyy/MM/dd"
ng-model="certificate.validityEnd" ng-model="certificate.validityEnd"
is-open="popup2.opened" is-open="popup2.opened"
@ -179,6 +178,7 @@
min-date="certificate.authority.authorityCertificate.minValidityEnd" min-date="certificate.authority.authorityCertificate.minValidityEnd"
alt-input-formats="altInputFormats" alt-input-formats="altInputFormats"
placeholder="End Date" placeholder="End Date"
readonly="true"
/> />
<span class="input-group-btn"> <span class="input-group-btn">
<button type="button" class="btn btn-default" ng-click="open2()"><i <button type="button" class="btn btn-default" ng-click="open2()"><i
@ -186,10 +186,6 @@
</span> </span>
</div> </div>
</div> </div>
<div class="col-sm-1">
<button uib-tooltip="Clear Validity" ng-click="clearDates()" class="btn btn-default"><i
class="glyphicon glyphicon-remove"></i></button>
</div>
</div> </div>
<div class="form-group" ng-show="certificate.authority.plugin.slug == 'acme-issuer'"> <div class="form-group" ng-show="certificate.authority.plugin.slug == 'acme-issuer'">
<label class="control-label col-sm-2"> <label class="control-label col-sm-2">

View File

@ -167,17 +167,19 @@ angular.module('lemur')
}, },
setValidityEndDateRange: function (value) { setValidityEndDateRange: function (value) {
// clear selected validity end date as we are about to calculate new range // clear selected validity end date as we are about to calculate new range
if(this.validityEnd) { this.validityEnd = '';
this.validityEnd = '';
}
// Minimum end date will be same as selected start date // Minimum end date will be same as selected start date
this.authority.authorityCertificate.minValidityEnd = value; this.authority.authorityCertificate.minValidityEnd = value;
// Move max end date by maxIssuanceDays if(!this.authority.maxIssuanceDays) {
let endDate = new Date(value); this.authority.authorityCertificate.maxValidityEnd = this.authority.authorityCertificate.notAfter;
endDate.setDate(endDate.getDate() + this.authority.authorityCertificate.maxIssuanceDays); } else {
this.authority.authorityCertificate.maxValidityEnd = endDate; // Move max end date by maxIssuanceDays
let endDate = new Date(value);
endDate.setDate(endDate.getDate() + this.authority.maxIssuanceDays);
this.authority.authorityCertificate.maxValidityEnd = endDate;
}
} }
}); });
}); });
@ -195,7 +197,7 @@ angular.module('lemur')
CertificateService.create = function (certificate) { CertificateService.create = function (certificate) {
certificate.attachSubAltName(); certificate.attachSubAltName();
certificate.attachCustom(); certificate.attachCustom();
if (certificate.validityYears === '') { // if a user de-selects validity years we ignore it if (certificate.validityYears === '') { // if a user de-selects validity years we ignore it - might not be needed anymore
delete certificate.validityYears; delete certificate.validityYears;
} }
return CertificateApi.post(certificate); return CertificateApi.post(certificate);
@ -281,9 +283,17 @@ angular.module('lemur')
certificate.authority.authorityCertificate.minValidityEnd = defaults.authority.authorityCertificate.notBefore; certificate.authority.authorityCertificate.minValidityEnd = defaults.authority.authorityCertificate.notBefore;
certificate.authority.authorityCertificate.maxValidityEnd = defaults.authority.authorityCertificate.notAfter; certificate.authority.authorityCertificate.maxValidityEnd = defaults.authority.authorityCertificate.notAfter;
// pre-select validity type radio button to default days
certificate.validityType = 'defaultDays';
if (certificate.dnsProviderId) { if (certificate.dnsProviderId) {
certificate.dnsProvider = {id: certificate.dnsProviderId}; certificate.dnsProvider = {id: certificate.dnsProviderId};
} }
if(!certificate.keyType) {
certificate.keyType = 'RSA2048'; // default algo to select during clone if backend did not return algo
}
}); });
}; };
@ -296,7 +306,7 @@ angular.module('lemur')
}; };
CertificateService.updateNotify = function (certificate) { CertificateService.updateNotify = function (certificate) {
return certificate.put(); return certificate.post();
}; };
CertificateService.export = function (certificate) { CertificateService.export = function (certificate) {

View File

@ -111,6 +111,8 @@
<div class="list-group-item"> <div class="list-group-item">
<dt>Key Length</dt> <dt>Key Length</dt>
<dd>{{ certificate.bits }}</dd> <dd>{{ certificate.bits }}</dd>
<dt>Key Type</dt>
<dd>{{ certificate.keyType }}</dd>
<dt>Signing Algorithm</dt> <dt>Signing Algorithm</dt>
<dd>{{ certificate.signingAlgorithm }}</dd> <dd>{{ certificate.signingAlgorithm }}</dd>
</div> </div>

View File

@ -147,17 +147,19 @@ angular.module('lemur')
}, },
setValidityEndDateRange: function (value) { setValidityEndDateRange: function (value) {
// clear selected validity end date as we are about to calculate new range // clear selected validity end date as we are about to calculate new range
if(this.validityEnd) { this.validityEnd = '';
this.validityEnd = '';
}
// Minimum end date will be same as selected start date // Minimum end date will be same as selected start date
this.authority.authorityCertificate.minValidityEnd = value; this.authority.authorityCertificate.minValidityEnd = value;
// Move max end date by maxIssuanceDays if(!this.authority.maxIssuanceDays) {
let endDate = new Date(value); this.authority.authorityCertificate.maxValidityEnd = this.authority.authorityCertificate.notAfter;
endDate.setDate(endDate.getDate() + this.authority.authorityCertificate.maxIssuanceDays); } else {
this.authority.authorityCertificate.maxValidityEnd = endDate; // Move max end date by maxIssuanceDays
let endDate = new Date(value);
endDate.setDate(endDate.getDate() + this.authority.maxIssuanceDays);
this.authority.authorityCertificate.maxValidityEnd = endDate;
}
} }
}); });
}); });

View File

@ -1,9 +1,21 @@
# This is just Python which means you can inherit and tweak settings # This is just Python which means you can inherit and tweak settings
import base64
import os import os
import random
import string
_basedir = os.path.abspath(os.path.dirname(__file__)) _basedir = os.path.abspath(os.path.dirname(__file__))
# generate random secrets for unittest
def get_random_secret(length):
secret_key = ''.join(random.choice(string.ascii_uppercase) for x in range(round(length / 4)))
secret_key = secret_key + ''.join(random.choice("~!@#$%^&*()_+") for x in range(round(length / 4)))
secret_key = secret_key + ''.join(random.choice(string.ascii_lowercase) for x in range(round(length / 4)))
return secret_key + ''.join(random.choice(string.digits) for x in range(round(length / 4)))
THREADS_PER_PAGE = 8 THREADS_PER_PAGE = 8
# General # General
@ -14,25 +26,27 @@ debug = False
TESTING = True TESTING = True
# this is the secret key used by flask session management # this is the secret key used by flask session management (utf8 encoded)
SECRET_KEY = "I/dVhOZNSMZMqrFJa5tWli6VQccOGudKerq3eWPMSzQNmHHVhMAQfQ==" SECRET_KEY = get_random_secret(length=32).encode('utf8')
# You should consider storing these separately from your config
# You should consider storing these separately from your config (should be URL-safe)
LEMUR_TOKEN_SECRET = "test" LEMUR_TOKEN_SECRET = "test"
LEMUR_ENCRYPTION_KEYS = "o61sBLNBSGtAckngtNrfVNd8xy8Hp9LBGDstTbMbqCY=" LEMUR_ENCRYPTION_KEYS = base64.urlsafe_b64encode(get_random_secret(length=32).encode('utf8'))
# List of domain regular expressions that non-admin users can issue # List of domain regular expressions that non-admin users can issue
LEMUR_WHITELISTED_DOMAINS = [ LEMUR_ALLOWED_DOMAINS = [
"^[a-zA-Z0-9-]+\.example\.com$", r"^[a-zA-Z0-9-]+\.example\.com$",
"^[a-zA-Z0-9-]+\.example\.org$", r"^[a-zA-Z0-9-]+\.example\.org$",
"^example\d+\.long\.com$", r"^example\d+\.long\.com$",
] ]
# Mail Server # Mail Server
# Lemur currently only supports SES for sending email, this address # Lemur currently only supports SES for sending email, this address
# needs to be verified # needs to be verified
LEMUR_EMAIL = "" LEMUR_EMAIL = "lemur@example.com"
LEMUR_SECURITY_TEAM_EMAIL = ["security@example.com"] LEMUR_SECURITY_TEAM_EMAIL = ["security@example.com"]
LEMUR_HOSTNAME = "lemur.example.com" LEMUR_HOSTNAME = "lemur.example.com"
@ -52,7 +66,8 @@ LEMUR_ALLOW_WEEKEND_EXPIRATION = False
# Database # Database
# modify this if you are not using a local database # modify this if you are not using a local database. Do not use any development or production DBs,
# as Unit Tests drop the whole schema, recreate and again drop everything at the end
SQLALCHEMY_DATABASE_URI = os.getenv( SQLALCHEMY_DATABASE_URI = os.getenv(
"SQLALCHEMY_DATABASE_URI", "postgresql://lemur:lemur@localhost:5432/lemur" "SQLALCHEMY_DATABASE_URI", "postgresql://lemur:lemur@localhost:5432/lemur"
) )
@ -84,8 +99,6 @@ DIGICERT_CIS_URL = "mock://www.digicert.com"
DIGICERT_CIS_PROFILE_NAMES = {"sha2-rsa-ecc-root": "ssl_plus"} DIGICERT_CIS_PROFILE_NAMES = {"sha2-rsa-ecc-root": "ssl_plus"}
DIGICERT_CIS_API_KEY = "api-key" DIGICERT_CIS_API_KEY = "api-key"
DIGICERT_CIS_ROOTS = {"root": "ROOT"} DIGICERT_CIS_ROOTS = {"root": "ROOT"}
DIGICERT_CIS_INTERMEDIATES = {"inter": "INTERMEDIATE_CA_CERT"}
VERISIGN_URL = "http://example.com" VERISIGN_URL = "http://example.com"
VERISIGN_PEM_PATH = "~/" VERISIGN_PEM_PATH = "~/"
@ -197,3 +210,41 @@ LDAP_REQUIRED_GROUP = "Lemur Access"
LDAP_DEFAULT_ROLE = "role1" LDAP_DEFAULT_ROLE = "role1"
ALLOW_CERT_DELETION = True ALLOW_CERT_DELETION = True
ENTRUST_API_CERT = "api-cert"
ENTRUST_API_KEY = get_random_secret(32)
ENTRUST_API_USER = "user"
ENTRUST_API_PASS = get_random_secret(32)
ENTRUST_URL = "https://api.entrust.net/enterprise/v2"
ENTRUST_ROOT = """
-----BEGIN CERTIFICATE-----
MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC
VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50
cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs
IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz
dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy
NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu
dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt
dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0
aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj
YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T
RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN
cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW
wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1
U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0
jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN
BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/
jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ
Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v
1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R
nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH
VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g==
-----END CERTIFICATE-----
"""
ENTRUST_NAME = "lemur"
ENTRUST_EMAIL = "lemur@example.com"
ENTRUST_PHONE = "123456"
ENTRUST_ISSUING = ""
ENTRUST_PRODUCT_ENTRUST = "ADVANTAGE_SSL"

View File

@ -34,6 +34,29 @@ def test_authority_input_schema(client, role, issuer_plugin, logged_in_user):
assert not errors assert not errors
def test_authority_input_schema_ecc(client, role, issuer_plugin, logged_in_user):
from lemur.authorities.schemas import AuthorityInputSchema
input_data = {
"name": "Example Authority",
"owner": "jim@example.com",
"description": "An example authority.",
"commonName": "An Example Authority",
"plugin": {
"slug": "test-issuer",
"plugin_options": [{"name": "test", "value": "blah"}],
},
"type": "root",
"signingAlgorithm": "sha256WithECDSA",
"keyType": "ECCPRIME256V1",
"sensitivity": "medium",
}
data, errors = AuthorityInputSchema().load(input_data)
assert not errors
def test_user_authority(session, client, authority, role, user, issuer_plugin): def test_user_authority(session, client, authority, role, user, issuer_plugin):
u = user["user"] u = user["user"]
u.roles.append(role) u.roles.append(role)

View File

@ -9,7 +9,6 @@ from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from marshmallow import ValidationError from marshmallow import ValidationError
from freezegun import freeze_time from freezegun import freeze_time
# from mock import patch
from unittest.mock import patch from unittest.mock import patch
from lemur.certificates.service import create_csr from lemur.certificates.service import create_csr
@ -154,7 +153,8 @@ def test_get_certificate_primitives(certificate):
with freeze_time(datetime.date(year=2016, month=10, day=30)): with freeze_time(datetime.date(year=2016, month=10, day=30)):
primitives = get_certificate_primitives(certificate) primitives = get_certificate_primitives(certificate)
assert len(primitives) == 26 assert len(primitives) == 25
assert (primitives["key_type"] == "RSA2048")
def test_certificate_output_schema(session, certificate, issuer_plugin): def test_certificate_output_schema(session, certificate, issuer_plugin):
@ -170,16 +170,52 @@ def test_certificate_output_schema(session, certificate, issuer_plugin):
) as wrapper: ) as wrapper:
data, errors = CertificateOutputSchema().dump(certificate) data, errors = CertificateOutputSchema().dump(certificate)
assert data["issuer"] == "LemurTrustUnittestsClass1CA2018" assert data["issuer"] == "LemurTrustUnittestsClass1CA2018"
assert data["distinguishedName"] == "L=Earth,ST=N/A,C=EE,OU=Karate Lessons,O=Daniel San & co,CN=san.example.org"
# Authority does not have 'cab_compliant', thus subject details should not be returned
assert "organization" not in data
assert wrapper.call_count == 1 assert wrapper.call_count == 1
def test_certificate_output_schema_subject_details(session, certificate, issuer_plugin):
from lemur.certificates.schemas import CertificateOutputSchema
from lemur.authorities.service import update_options
# Mark authority as non-cab-compliant
update_options(certificate.authority.id, '[{"name": "cab_compliant","value":false}]')
data, errors = CertificateOutputSchema().dump(certificate)
assert not errors
assert data["issuer"] == "LemurTrustUnittestsClass1CA2018"
assert data["distinguishedName"] == "L=Earth,ST=N/A,C=EE,OU=Karate Lessons,O=Daniel San & co,CN=san.example.org"
# Original subject details should be returned because of cab_compliant option update above
assert data["country"] == "EE"
assert data["state"] == "N/A"
assert data["location"] == "Earth"
assert data["organization"] == "Daniel San & co"
assert data["organizationalUnit"] == "Karate Lessons"
# Mark authority as cab-compliant
update_options(certificate.authority.id, '[{"name": "cab_compliant","value":true}]')
data, errors = CertificateOutputSchema().dump(certificate)
assert not errors
assert "country" not in data
assert "state" not in data
assert "location" not in data
assert "organization" not in data
assert "organizationalUnit" not in data
def test_certificate_edit_schema(session): def test_certificate_edit_schema(session):
from lemur.certificates.schemas import CertificateEditInputSchema from lemur.certificates.schemas import CertificateEditInputSchema
input_data = {"owner": "bob@example.com"} input_data = {"owner": "bob@example.com"}
data, errors = CertificateEditInputSchema().load(input_data) data, errors = CertificateEditInputSchema().load(input_data)
assert not errors
assert len(data["notifications"]) == 3 assert len(data["notifications"]) == 3
assert data["roles"][0].name == input_data["owner"]
def test_authority_key_identifier_schema(): def test_authority_key_identifier_schema():
@ -253,17 +289,18 @@ def test_certificate_input_schema(client, authority):
"validityStart": arrow.get(2018, 11, 9).isoformat(), "validityStart": arrow.get(2018, 11, 9).isoformat(),
"validityEnd": arrow.get(2019, 11, 9).isoformat(), "validityEnd": arrow.get(2019, 11, 9).isoformat(),
"dnsProvider": None, "dnsProvider": None,
"location": "A Place"
} }
data, errors = CertificateInputSchema().load(input_data) data, errors = CertificateInputSchema().load(input_data)
assert not errors assert not errors
assert data["authority"].id == authority.id assert data["authority"].id == authority.id
assert data["location"] == "A Place"
# make sure the defaults got set # make sure the defaults got set
assert data["common_name"] == "test.example.com" assert data["common_name"] == "test.example.com"
assert data["country"] == "US" assert data["country"] == "US"
assert data["location"] == "Los Gatos"
assert len(data.keys()) == 19 assert len(data.keys()) == 19
@ -395,7 +432,7 @@ def test_certificate_cn_admin(client, authority, logged_in_admin):
from lemur.certificates.schemas import CertificateInputSchema from lemur.certificates.schemas import CertificateInputSchema
input_data = { input_data = {
"commonName": "*.admin-overrides-whitelist.com", "commonName": "*.admin-overrides-allowlist.com",
"owner": "jim@example.com", "owner": "jim@example.com",
"authority": {"id": authority.id}, "authority": {"id": authority.id},
"description": "testtestest", "description": "testtestest",
@ -456,7 +493,7 @@ def test_certificate_incative_authority(client, authority, session, logged_in_us
def test_certificate_disallowed_names(client, authority, session, logged_in_user): def test_certificate_disallowed_names(client, authority, session, logged_in_user):
"""The CN and SAN are disallowed by LEMUR_WHITELISTED_DOMAINS.""" """The CN and SAN are disallowed by LEMUR_ALLOWED_DOMAINS."""
from lemur.certificates.schemas import CertificateInputSchema from lemur.certificates.schemas import CertificateInputSchema
input_data = { input_data = {
@ -479,10 +516,10 @@ def test_certificate_disallowed_names(client, authority, session, logged_in_user
data, errors = CertificateInputSchema().load(input_data) data, errors = CertificateInputSchema().load(input_data)
assert errors["common_name"][0].startswith( assert errors["common_name"][0].startswith(
"Domain *.example.com does not match whitelisted domain patterns" "Domain *.example.com does not match allowed domain patterns"
) )
assert errors["extensions"]["sub_alt_names"]["names"][0].startswith( assert errors["extensions"]["sub_alt_names"]["names"][0].startswith(
"Domain evilhacker.org does not match whitelisted domain patterns" "Domain evilhacker.org does not match allowed domain patterns"
) )
@ -669,7 +706,7 @@ def test_csr_empty_san(client):
def test_csr_disallowed_cn(client, logged_in_user): def test_csr_disallowed_cn(client, logged_in_user):
"""Domain name CN is disallowed via LEMUR_WHITELISTED_DOMAINS.""" """Domain name CN is disallowed via LEMUR_ALLOWED_DOMAINS."""
from lemur.common import validators from lemur.common import validators
request, pkey = create_csr( request, pkey = create_csr(
@ -678,12 +715,12 @@ def test_csr_disallowed_cn(client, logged_in_user):
with pytest.raises(ValidationError) as err: with pytest.raises(ValidationError) as err:
validators.csr(request) validators.csr(request)
assert str(err.value).startswith( assert str(err.value).startswith(
"Domain evilhacker.org does not match whitelisted domain patterns" "Domain evilhacker.org does not match allowed domain patterns"
) )
def test_csr_disallowed_san(client, logged_in_user): def test_csr_disallowed_san(client, logged_in_user):
"""SAN name is disallowed by LEMUR_WHITELISTED_DOMAINS.""" """SAN name is disallowed by LEMUR_ALLOWED_DOMAINS."""
from lemur.common import validators from lemur.common import validators
request, pkey = create_csr( request, pkey = create_csr(
@ -699,7 +736,7 @@ def test_csr_disallowed_san(client, logged_in_user):
with pytest.raises(ValidationError) as err: with pytest.raises(ValidationError) as err:
validators.csr(request) validators.csr(request)
assert str(err.value).startswith( assert str(err.value).startswith(
"Domain evilhacker.org does not match whitelisted domain patterns" "Domain evilhacker.org does not match allowed domain patterns"
) )
@ -754,11 +791,22 @@ def test_reissue_certificate(
issuer_plugin, crypto_authority, certificate, logged_in_user issuer_plugin, crypto_authority, certificate, logged_in_user
): ):
from lemur.certificates.service import reissue_certificate from lemur.certificates.service import reissue_certificate
from lemur.authorities.service import update_options
from lemur.tests.conf import LEMUR_DEFAULT_ORGANIZATION
# test-authority would return a mismatching private key, so use 'cryptography-issuer' plugin instead. # test-authority would return a mismatching private key, so use 'cryptography-issuer' plugin instead.
certificate.authority = crypto_authority certificate.authority = crypto_authority
new_cert = reissue_certificate(certificate) new_cert = reissue_certificate(certificate)
assert new_cert assert new_cert
assert new_cert.key_type == "RSA2048"
assert new_cert.organization != certificate.organization
# Check for default value since authority does not have cab_compliant option set
assert new_cert.organization == LEMUR_DEFAULT_ORGANIZATION
# update cab_compliant option to false for crypto_authority to maintain subject details
update_options(crypto_authority.id, '[{"name": "cab_compliant","value":false}]')
new_cert = reissue_certificate(certificate)
assert new_cert.organization == certificate.organization
def test_create_csr(): def test_create_csr():
@ -915,24 +963,38 @@ def test_certificate_get_body(client):
"CN=LemurTrust Unittests Class 1 CA 2018" "CN=LemurTrust Unittests Class 1 CA 2018"
) )
# No authority details are provided in this test, no information about being cab_compliant is available.
# Thus original subject details should be returned.
assert response_body["country"] == "EE"
assert response_body["state"] == "N/A"
assert response_body["location"] == "Earth"
assert response_body["organization"] == "LemurTrust Enterprises Ltd"
assert response_body["organizationalUnit"] == "Unittesting Operations Center"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"token,status", "token,status",
[ [
(VALID_USER_HEADER_TOKEN, 405), (VALID_USER_HEADER_TOKEN, 403),
(VALID_ADMIN_HEADER_TOKEN, 405), (VALID_ADMIN_HEADER_TOKEN, 200),
(VALID_ADMIN_API_TOKEN, 405), (VALID_ADMIN_API_TOKEN, 200),
("", 405), ("", 401),
], ],
) )
def test_certificate_post(client, token, status): def test_certificate_post_update_notify(client, certificate, token, status):
assert ( # negate the current notify flag and pass it to update POST call to flip the notify
client.post( toggled_notify = not certificate.notify
api.url_for(Certificates, certificate_id=1), data={}, headers=token
).status_code response = client.post(
== status api.url_for(Certificates, certificate_id=certificate.id),
data=json.dumps({"notify": toggled_notify}),
headers=token
) )
assert response.status_code == status
if status == 200:
assert response.json.get("notify") == toggled_notify
@pytest.mark.parametrize( @pytest.mark.parametrize(
"token,status", "token,status",
@ -961,6 +1023,9 @@ def test_certificate_put_with_data(client, certificate, issuer_plugin):
headers=VALID_ADMIN_HEADER_TOKEN, headers=VALID_ADMIN_HEADER_TOKEN,
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert len(certificate.notifications) == 3
assert certificate.roles[0].name == "bob@example.com"
assert certificate.notify
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -1,11 +1,18 @@
from datetime import timedelta
import arrow
import boto3
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
from datetime import timedelta
import arrow
from moto import mock_ses from moto import mock_ses
@mock_ses
def verify_sender_email():
ses_client = boto3.client("ses", region_name="us-east-1")
ses_client.verify_email_identity(EmailAddress="lemur@example.com")
def test_needs_notification(app, certificate, notification): def test_needs_notification(app, certificate, notification):
from lemur.notifications.messaging import needs_notification from lemur.notifications.messaging import needs_notification
@ -78,6 +85,7 @@ def test_get_eligible_certificates(app, certificate, notification):
@mock_ses @mock_ses
def test_send_expiration_notification(certificate, notification, notification_plugin): def test_send_expiration_notification(certificate, notification, notification_plugin):
from lemur.notifications.messaging import send_expiration_notifications from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email()
certificate.notifications.append(notification) certificate.notifications.append(notification)
certificate.notifications[0].options = [ certificate.notifications[0].options = [
@ -87,7 +95,9 @@ def test_send_expiration_notification(certificate, notification, notification_pl
delta = certificate.not_after - timedelta(days=10) delta = certificate.not_after - timedelta(days=10)
with freeze_time(delta.datetime): 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 @mock_ses
@ -104,5 +114,14 @@ def test_send_expiration_notification_with_no_notifications(
@mock_ses @mock_ses
def test_send_rotation_notification(notification_plugin, certificate): def test_send_rotation_notification(notification_plugin, certificate):
from lemur.notifications.messaging import send_rotation_notification from lemur.notifications.messaging import send_rotation_notification
verify_sender_email()
send_rotation_notification(certificate, notification_plugin=notification_plugin) assert send_rotation_notification(certificate)
@mock_ses
def test_send_pending_failure_notification(notification_plugin, async_issuer_plugin, pending_certificate):
from lemur.notifications.messaging import send_pending_failure_notification
verify_sender_email()
assert send_pending_failure_notification(pending_certificate)

View File

@ -55,6 +55,7 @@ def test_create_pending(pending_certificate, user, session):
assert real_cert.notify == pending_certificate.notify assert real_cert.notify == pending_certificate.notify
assert real_cert.private_key == pending_certificate.private_key assert real_cert.private_key == pending_certificate.private_key
assert real_cert.external_id == "54321" assert real_cert.external_id == "54321"
assert real_cert.key_type == "RSA2048"
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -2,15 +2,24 @@ import pytest
from lemur.tests.vectors import ( from lemur.tests.vectors import (
SAN_CERT, SAN_CERT,
SAN_CERT_STR,
INTERMEDIATE_CERT, INTERMEDIATE_CERT,
ROOTCA_CERT, ROOTCA_CERT,
EC_CERT_EXAMPLE, EC_CERT_EXAMPLE,
ECDSA_PRIME256V1_CERT, ECDSA_PRIME256V1_CERT,
ECDSA_SECP384r1_CERT, ECDSA_SECP384r1_CERT,
ECDSA_SECP384r1_CERT_STR,
DSA_CERT, DSA_CERT,
CERT_CHAIN_PKCS7_PEM
) )
def test_get_key_type_from_ec_curve():
from lemur.common.utils import get_key_type_from_ec_curve
assert get_key_type_from_ec_curve("secp256r1") == "ECCPRIME256V1"
def test_generate_private_key(): def test_generate_private_key():
from lemur.common.utils import generate_private_key from lemur.common.utils import generate_private_key
@ -100,3 +109,22 @@ def test_is_selfsigned(selfsigned_cert):
# unsupported algorithm (DSA) # unsupported algorithm (DSA)
with pytest.raises(Exception): with pytest.raises(Exception):
is_selfsigned(DSA_CERT) is_selfsigned(DSA_CERT)
def test_get_key_type_from_certificate():
from lemur.common.utils import get_key_type_from_certificate
assert (get_key_type_from_certificate(SAN_CERT_STR) == "RSA2048")
assert (get_key_type_from_certificate(ECDSA_SECP384r1_CERT_STR) == "ECCSECP384R1")
def test_convert_pkcs7_bytes_to_pem():
from lemur.common.utils import convert_pkcs7_bytes_to_pem
from lemur.common.utils import parse_certificate
cert_chain = convert_pkcs7_bytes_to_pem(CERT_CHAIN_PKCS7_PEM)
assert(len(cert_chain) == 3)
leaf = cert_chain[1]
root = cert_chain[2]
assert(parse_certificate("\n".join(str(root).splitlines())) == ROOTCA_CERT)
assert (parse_certificate("\n".join(str(leaf).splitlines())) == INTERMEDIATE_CERT)

View File

@ -512,3 +512,78 @@ BglghkgBZQMEAwIDMAAwLQIVANubSNMSLt8plN9ZV3cp4pe3lMYCAhQPLLE7rTgm
-----END CERTIFICATE----- -----END CERTIFICATE-----
""" """
DSA_CERT = parse_certificate(DSA_CERT_STR) DSA_CERT = parse_certificate(DSA_CERT_STR)
CERT_CHAIN_PKCS7_STR = """
-----BEGIN PKCS7-----
MIIMfwYJKoZIhvcNAQcCoIIMcDCCDGwCAQExADALBgkqhkiG9w0BBwGgggxSMIIE
FjCCAv6gAwIBAgIQbIbX/Ap0Roqzf5HeN5akmzANBgkqhkiG9w0BAQsFADCBpDEq
MCgGA1UEAwwhTGVtdXJUcnVzdCBVbml0dGVzdHMgUm9vdCBDQSAyMDE4MSMwIQYD
VQQKDBpMZW11clRydXN0IEVudGVycHJpc2VzIEx0ZDEmMCQGA1UECwwdVW5pdHRl
c3RpbmcgT3BlcmF0aW9ucyBDZW50ZXIxCzAJBgNVBAYTAkVFMQwwCgYDVQQIDANO
L0ExDjAMBgNVBAcMBUVhcnRoMB4XDTE3MTIzMTIyMDAwMFoXDTQ3MTIzMTIyMDAw
MFowgaQxKjAoBgNVBAMMIUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAx
ODEjMCEGA1UECgwaTGVtdXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsM
HVVuaXR0ZXN0aW5nIE9wZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoG
A1UECAwDTi9BMQ4wDAYDVQQHDAVFYXJ0aDCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAL8laXtLXyM64t5dz2B9q+4VvOsChefBi2PlGudqxDuRN3l0Kmcf
un6x2Gng24pTlGdtmiTEWA0a2F8HRLv4YBWhuYleVeBPtf1fF1/SuYgkJOWT7S5q
k/od/tUOLHS0Y067st3FydnFQTKpAuYveEkxleFrMS8hX8cuEgbER+8ybiXKn4Gs
yM/om6lsTyBoaLp5yTAoQb4jAWDbiz1xcjPSkvH2lm7rLGtKoylCYwxRsMh2nZcR
r1OXVhYHXwpYHVB/jVAjy7PAWQ316hi6mpPYbBV+yfn2GUfGuytqyoXLEsrM3iEE
AkU0mJjQmYsCDM3r7ONHTM+UFEk47HCZJccCAwEAAaNCMEAwDwYDVR0TAQH/BAUw
AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFL12SFeOTTDdGKsHKozeByG
HY6nMA0GCSqGSIb3DQEBCwUAA4IBAQAJfe0/uAHobkxth38dqrSFmTo+D5/TMlRt
3hdgjlah6sD2+/DObCyut/XhQWCgTNWyRi4xTKgLh5KSoeJ9EMkADGEgDkU2vjBg
5FmGZsxg6bqjxehK+2HvASJoTH8r41xmTioav7a2i3wNhaNSntw2QRTQBQEDOIzH
RpPDQ2quErjA8nSifE2xmAAr3g+FuookTTJuv37s2cS59zRYsg+WC3+TtPpRssvo
bJ6Xe2D4cCVjUmsqtFEztMgdqgmlcWyGdUKeXdi7CMoeTb4uO+9qRQq46wYWn7K1
z+W0Kp5yhnnPAoOioAP4vjASDx3z3RnLaZvMmcO7YdCIwhE5oGV0MIIEGjCCAwKg
AwIBAgIRAJ96dbOdrkw/lSTGiwbaagwwDQYJKoZIhvcNAQELBQAwgaQxKjAoBgNV
BAMMIUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAxODEjMCEGA1UECgwa
TGVtdXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsMHVVuaXR0ZXN0aW5n
IE9wZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoGA1UECAwDTi9BMQ4w
DAYDVQQHDAVFYXJ0aDAeFw0xNzEyMzEyMjAwMDBaFw00NzEyMzEyMjAwMDBaMIGn
MS0wKwYDVQQDDCRMZW11clRydXN0IFVuaXR0ZXN0cyBDbGFzcyAxIENBIDIwMTgx
IzAhBgNVBAoMGkxlbXVyVHJ1c3QgRW50ZXJwcmlzZXMgTHRkMSYwJAYDVQQLDB1V
bml0dGVzdGluZyBPcGVyYXRpb25zIENlbnRlcjELMAkGA1UEBhMCRUUxDDAKBgNV
BAgMA04vQTEOMAwGA1UEBwwFRWFydGgwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQDR+qNdfNsLhGvgw3IgCQNakL2B9dpQtkVnvAXhdRZqJETm/tHLkGvO
NWTXAwGdoiKv6+0j3I5InUsW+wzUPewcfj+PLNu4mFMq8jH/gPhTElKiAztPRdm8
QKchvrqiaU6uEbia8ClM6uPpIi8StxE1aJRYL03p0WeMJjJPrsl6eSSdpR4qL69G
Td1n5je9OuWAcn5utXXnt/jO4vNeFRjlGp/0n3JmTDd9w4vtAyY9UrdGgo37eBmi
6mXt5J9i//NenhaiOVU81RqxZM2Jt1kkg2WSjcqcIQfBEWp9StG46VmHLaL+9/v2
XAV3tL1VilJGj6PoFMb4gY5MXthfGSiXAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQstpQr0iMBVfv0lODIsMgT9+9o
ezANBgkqhkiG9w0BAQsFAAOCAQEASYQbv1Qwb5zES6Gb5LEhrAcH81ZB2uIpKd3K
i6AS4fLJVymMGkUs0RZjt39Ep4qX1zf0hn82Yh9YwRalrkgu+tzKrp0JgegNe6+g
yFRrJC0SIGA4zc3M02m/n4tdaouU2lp6jhmWruL3g25ZkgbQ8LO2zjpSMtblR2eu
vR2+bI7TepklyG71qx5y6/N8x5PT+hnTlleiZeE/ji9D96MZlpWB4kBihekWmxup
tED22z/tpQtac+hPBNgt8z1uFVEYN2rKEcCE7V6Qk7icS+M4Vb7M3D8kLyWDubs9
Yy3l0EWjOXQXxEhTaKEm4gSuY/j+Y35bBVkA2Fcyuq7msiTgrzCCBBYwggL+oAMC
AQICEGyG1/wKdEaKs3+R3jeWpJswDQYJKoZIhvcNAQELBQAwgaQxKjAoBgNVBAMM
IUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAxODEjMCEGA1UECgwaTGVt
dXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsMHVVuaXR0ZXN0aW5nIE9w
ZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoGA1UECAwDTi9BMQ4wDAYD
VQQHDAVFYXJ0aDAeFw0xNzEyMzEyMjAwMDBaFw00NzEyMzEyMjAwMDBaMIGkMSow
KAYDVQQDDCFMZW11clRydXN0IFVuaXR0ZXN0cyBSb290IENBIDIwMTgxIzAhBgNV
BAoMGkxlbXVyVHJ1c3QgRW50ZXJwcmlzZXMgTHRkMSYwJAYDVQQLDB1Vbml0dGVz
dGluZyBPcGVyYXRpb25zIENlbnRlcjELMAkGA1UEBhMCRUUxDDAKBgNVBAgMA04v
QTEOMAwGA1UEBwwFRWFydGgwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
AQC/JWl7S18jOuLeXc9gfavuFbzrAoXnwYtj5RrnasQ7kTd5dCpnH7p+sdhp4NuK
U5RnbZokxFgNGthfB0S7+GAVobmJXlXgT7X9Xxdf0rmIJCTlk+0uapP6Hf7VDix0
tGNOu7LdxcnZxUEyqQLmL3hJMZXhazEvIV/HLhIGxEfvMm4lyp+BrMjP6JupbE8g
aGi6eckwKEG+IwFg24s9cXIz0pLx9pZu6yxrSqMpQmMMUbDIdp2XEa9Tl1YWB18K
WB1Qf41QI8uzwFkN9eoYupqT2GwVfsn59hlHxrsrasqFyxLKzN4hBAJFNJiY0JmL
AgzN6+zjR0zPlBRJOOxwmSXHAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRS9dkhXjk0w3RirByqM3gchh2OpzANBgkq
hkiG9w0BAQsFAAOCAQEACX3tP7gB6G5MbYd/Haq0hZk6Pg+f0zJUbd4XYI5WoerA
9vvwzmwsrrf14UFgoEzVskYuMUyoC4eSkqHifRDJAAxhIA5FNr4wYORZhmbMYOm6
o8XoSvth7wEiaEx/K+NcZk4qGr+2tot8DYWjUp7cNkEU0AUBAziMx0aTw0NqrhK4
wPJ0onxNsZgAK94PhbqKJE0ybr9+7NnEufc0WLIPlgt/k7T6UbLL6Gyel3tg+HAl
Y1JrKrRRM7TIHaoJpXFshnVCnl3YuwjKHk2+LjvvakUKuOsGFp+ytc/ltCqecoZ5
zwKDoqAD+L4wEg8d890Zy2mbzJnDu2HQiMIROaBldKEAMQA=
-----END PKCS7-----
"""
CERT_CHAIN_PKCS7_PEM = CERT_CHAIN_PKCS7_STR.encode('utf-8')

View File

@ -39,7 +39,7 @@
"gulp-uglify": "^2.0.0", "gulp-uglify": "^2.0.0",
"gulp-useref": "^3.1.2", "gulp-useref": "^3.1.2",
"gulp-util": "^3.0.1", "gulp-util": "^3.0.1",
"http-proxy": "~1.16.2", "http-proxy": ">=1.18.1",
"jshint-stylish": "^2.2.1", "jshint-stylish": "^2.2.1",
"karma": "^4.4.1", "karma": "^4.4.1",
"karma-jasmine": "^1.1.0", "karma-jasmine": "^1.1.0",

View File

@ -11,7 +11,7 @@ cffi==1.14.0 # via cryptography
cfgv==3.1.0 # via pre-commit cfgv==3.1.0 # via pre-commit
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
colorama==0.4.3 # via twine colorama==0.4.3 # via twine
cryptography==3.0 # via secretstorage cryptography==3.2 # via secretstorage
distlib==0.3.0 # via virtualenv distlib==0.3.0 # via virtualenv
docutils==0.16 # via readme-renderer docutils==0.16 # via readme-renderer
filelock==3.0.12 # via virtualenv filelock==3.0.12 # via virtualenv

View File

@ -4,21 +4,21 @@
# #
# pip-compile --no-index --output-file=requirements-docs.txt requirements-docs.in # pip-compile --no-index --output-file=requirements-docs.txt requirements-docs.in
# #
acme==1.7.0 # via -r requirements.txt acme==1.9.0 # via -r requirements.txt
alabaster==0.7.12 # via sphinx alabaster==0.7.12 # via sphinx
alembic-autogenerate-enums==0.0.2 # via -r requirements.txt alembic-autogenerate-enums==0.0.2 # via -r requirements.txt
alembic==1.4.2 # via -r requirements.txt, flask-migrate alembic==1.4.2 # via -r requirements.txt, flask-migrate
amqp==2.5.2 # via -r requirements.txt, kombu amqp==2.5.2 # via -r requirements.txt, kombu
aniso8601==8.0.0 # via -r requirements.txt, flask-restful aniso8601==8.0.0 # via -r requirements.txt, flask-restful
arrow==0.16.0 # via -r requirements.txt arrow==0.17.0 # via -r requirements.txt
asyncpool==1.0 # via -r requirements.txt asyncpool==1.0 # via -r requirements.txt
babel==2.8.0 # via sphinx babel==2.8.0 # via sphinx
bcrypt==3.1.7 # via -r requirements.txt, flask-bcrypt, paramiko bcrypt==3.1.7 # via -r requirements.txt, flask-bcrypt, paramiko
beautifulsoup4==4.9.1 # via -r requirements.txt, cloudflare beautifulsoup4==4.9.1 # via -r requirements.txt, cloudflare
billiard==3.6.3.0 # via -r requirements.txt, celery billiard==3.6.3.0 # via -r requirements.txt, celery
blinker==1.4 # via -r requirements.txt, flask-mail, flask-principal, raven blinker==1.4 # via -r requirements.txt, flask-mail, flask-principal, raven
boto3==1.14.48 # via -r requirements.txt boto3==1.16.5 # via -r requirements.txt
botocore==1.17.48 # via -r requirements.txt, boto3, s3transfer botocore==1.19.5 # via -r requirements.txt, boto3, s3transfer
celery[redis]==4.4.2 # via -r requirements.txt celery[redis]==4.4.2 # via -r requirements.txt
certifi==2020.6.20 # via -r requirements.txt, requests certifi==2020.6.20 # via -r requirements.txt, requests
certsrv==2.1.1 # via -r requirements.txt certsrv==2.1.1 # via -r requirements.txt
@ -26,13 +26,13 @@ cffi==1.14.0 # via -r requirements.txt, bcrypt, cryptography, pynac
chardet==3.0.4 # via -r requirements.txt, requests chardet==3.0.4 # via -r requirements.txt, requests
click==7.1.1 # via -r requirements.txt, flask click==7.1.1 # via -r requirements.txt, flask
cloudflare==2.8.13 # via -r requirements.txt cloudflare==2.8.13 # via -r requirements.txt
cryptography==3.0 # via -r requirements.txt, acme, josepy, paramiko, pyopenssl, requests cryptography==3.2 # via -r requirements.txt, acme, josepy, paramiko, pyopenssl, requests
dnspython3==1.15.0 # via -r requirements.txt dnspython3==1.15.0 # via -r requirements.txt
dnspython==1.15.0 # via -r requirements.txt, dnspython3 dnspython==1.15.0 # via -r requirements.txt, dnspython3
docutils==0.15.2 # via -r requirements.txt, botocore, sphinx docutils==0.15.2 # via sphinx
dyn==1.8.1 # via -r requirements.txt dyn==1.8.1 # via -r requirements.txt
flask-bcrypt==0.7.1 # via -r requirements.txt flask-bcrypt==0.7.1 # via -r requirements.txt
flask-cors==3.0.8 # via -r requirements.txt flask-cors==3.0.9 # via -r requirements.txt
flask-mail==0.9.1 # via -r requirements.txt flask-mail==0.9.1 # via -r requirements.txt
flask-migrate==2.5.3 # via -r requirements.txt flask-migrate==2.5.3 # via -r requirements.txt
flask-principal==0.4.0 # via -r requirements.txt flask-principal==0.4.0 # via -r requirements.txt
@ -62,9 +62,9 @@ marshmallow-sqlalchemy==0.23.1 # via -r requirements.txt
marshmallow==2.20.4 # via -r requirements.txt, marshmallow-sqlalchemy marshmallow==2.20.4 # via -r requirements.txt, marshmallow-sqlalchemy
ndg-httpsclient==0.5.1 # via -r requirements.txt ndg-httpsclient==0.5.1 # via -r requirements.txt
packaging==20.3 # via sphinx packaging==20.3 # via sphinx
paramiko==2.7.1 # via -r requirements.txt paramiko==2.7.2 # via -r requirements.txt
pem==20.1.0 # via -r requirements.txt pem==20.1.0 # via -r requirements.txt
psycopg2==2.8.5 # via -r requirements.txt psycopg2==2.8.6 # via -r requirements.txt
pyasn1-modules==0.2.8 # via -r requirements.txt, pyjks, python-ldap pyasn1-modules==0.2.8 # via -r requirements.txt, pyjks, python-ldap
pyasn1==0.4.8 # via -r requirements.txt, ndg-httpsclient, pyasn1-modules, pyjks, python-ldap pyasn1==0.4.8 # via -r requirements.txt, ndg-httpsclient, pyasn1-modules, pyjks, python-ldap
pycparser==2.20 # via -r requirements.txt, cffi pycparser==2.20 # via -r requirements.txt, cffi

View File

@ -5,30 +5,29 @@
# pip-compile --no-index --output-file=requirements-tests.txt requirements-tests.in # pip-compile --no-index --output-file=requirements-tests.txt requirements-tests.in
# #
appdirs==1.4.3 # via black appdirs==1.4.3 # via black
attrs==19.3.0 # via black, jsonschema, pytest attrs==19.3.0 # via jsonschema, pytest
aws-sam-translator==1.22.0 # via cfn-lint aws-sam-translator==1.22.0 # via cfn-lint
aws-xray-sdk==2.5.0 # via moto aws-xray-sdk==2.5.0 # via moto
bandit==1.6.2 # via -r requirements-tests.in bandit==1.6.2 # via -r requirements-tests.in
black==19.10b0 # via -r requirements-tests.in black==20.8b1 # via -r requirements-tests.in
boto3==1.14.48 # via aws-sam-translator, moto boto3==1.16.5 # via aws-sam-translator, moto
boto==2.49.0 # via moto boto==2.49.0 # via moto
botocore==1.17.48 # via aws-xray-sdk, boto3, moto, s3transfer botocore==1.19.5 # via aws-xray-sdk, boto3, moto, s3transfer
certifi==2020.6.20 # via requests certifi==2020.6.20 # via requests
cffi==1.14.0 # via cryptography cffi==1.14.0 # via cryptography
cfn-lint==0.29.5 # via moto cfn-lint==0.29.5 # via moto
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
click==7.1.1 # via black, flask click==7.1.2 # via black, flask
coverage==5.2.1 # via -r requirements-tests.in coverage==5.3 # via -r requirements-tests.in
cryptography==3.0 # via moto, sshpubkeys cryptography==3.2 # via moto, python-jose, sshpubkeys
decorator==4.4.2 # via networkx decorator==4.4.2 # via networkx
docker==4.2.0 # via moto docker==4.2.0 # via moto
docutils==0.15.2 # via botocore ecdsa==0.14.1 # via moto, python-jose, sshpubkeys
ecdsa==0.15 # via python-jose, sshpubkeys factory-boy==3.1.0 # via -r requirements-tests.in
factory-boy==3.0.1 # via -r requirements-tests.in faker==4.14.0 # via -r requirements-tests.in, factory-boy
faker==4.1.2 # via -r requirements-tests.in, factory-boy fakeredis==1.4.4 # via -r requirements-tests.in
fakeredis==1.4.3 # via -r requirements-tests.in
flask==1.1.2 # via pytest-flask flask==1.1.2 # via pytest-flask
freezegun==0.3.15 # via -r requirements-tests.in freezegun==1.0.0 # via -r requirements-tests.in
future==0.18.2 # via aws-xray-sdk future==0.18.2 # via aws-xray-sdk
gitdb==4.0.4 # via gitpython gitdb==4.0.4 # via gitpython
gitpython==3.1.1 # via bandit gitpython==3.1.1 # via bandit
@ -43,10 +42,11 @@ jsonpatch==1.25 # via cfn-lint
jsonpickle==1.4 # via aws-xray-sdk jsonpickle==1.4 # via aws-xray-sdk
jsonpointer==2.0 # via jsonpatch jsonpointer==2.0 # via jsonpatch
jsonschema==3.2.0 # via aws-sam-translator, cfn-lint jsonschema==3.2.0 # via aws-sam-translator, cfn-lint
markupsafe==1.1.1 # via jinja2 markupsafe==1.1.1 # via jinja2, moto
mock==4.0.2 # via moto mock==4.0.2 # via moto
more-itertools==8.2.0 # via pytest more-itertools==8.2.0 # via moto
moto==1.3.14 # via -r requirements-tests.in moto==1.3.16 # via -r requirements-tests.in
mypy-extensions==0.4.3 # via black
networkx==2.4 # via cfn-lint networkx==2.4 # via cfn-lint
nose==1.3.7 # via -r requirements-tests.in nose==1.3.7 # via -r requirements-tests.in
packaging==20.3 # via pytest packaging==20.3 # via pytest
@ -60,10 +60,10 @@ pyflakes==2.2.0 # via -r requirements-tests.in
pyparsing==2.4.7 # via packaging pyparsing==2.4.7 # via packaging
pyrsistent==0.16.0 # via jsonschema pyrsistent==0.16.0 # via jsonschema
pytest-flask==1.0.0 # via -r requirements-tests.in pytest-flask==1.0.0 # via -r requirements-tests.in
pytest-mock==3.3.0 # via -r requirements-tests.in pytest-mock==3.3.1 # via -r requirements-tests.in
pytest==6.0.1 # via -r requirements-tests.in, pytest-flask, pytest-mock pytest==6.1.1 # via -r requirements-tests.in, pytest-flask, pytest-mock
python-dateutil==2.8.1 # via botocore, faker, freezegun, moto python-dateutil==2.8.1 # via botocore, faker, freezegun, moto
python-jose==3.1.0 # via moto python-jose[cryptography]==3.1.0 # via moto
pytz==2019.3 # via moto pytz==2019.3 # via moto
pyyaml==5.3.1 # via -r requirements-tests.in, bandit, cfn-lint, moto pyyaml==5.3.1 # via -r requirements-tests.in, bandit, cfn-lint, moto
redis==3.5.3 # via fakeredis redis==3.5.3 # via fakeredis
@ -73,20 +73,21 @@ requests==2.24.0 # via docker, moto, requests-mock, responses
responses==0.10.12 # via moto responses==0.10.12 # via moto
rsa==4.0 # via python-jose rsa==4.0 # via python-jose
s3transfer==0.3.3 # via boto3 s3transfer==0.3.3 # via boto3
six==1.15.0 # via aws-sam-translator, bandit, cfn-lint, cryptography, docker, ecdsa, fakeredis, freezegun, jsonschema, moto, packaging, pyrsistent, python-dateutil, python-jose, requests-mock, responses, stevedore, websocket-client six==1.15.0 # via aws-sam-translator, bandit, cfn-lint, cryptography, docker, ecdsa, fakeredis, jsonschema, moto, packaging, pyrsistent, python-dateutil, python-jose, requests-mock, responses, stevedore, websocket-client
smmap==3.0.2 # via gitdb smmap==3.0.2 # via gitdb
sortedcontainers==2.1.0 # via fakeredis sortedcontainers==2.1.0 # via fakeredis
sshpubkeys==3.1.0 # via moto sshpubkeys==3.1.0 # via moto
stevedore==1.32.0 # via bandit stevedore==1.32.0 # via bandit
text-unidecode==1.3 # via faker text-unidecode==1.3 # via faker
toml==0.10.0 # via black, pytest toml==0.10.1 # via black, pytest
typed-ast==1.4.1 # via black typed-ast==1.4.1 # via black
typing-extensions==3.7.4.3 # via black
urllib3==1.25.8 # via botocore, requests urllib3==1.25.8 # via botocore, requests
websocket-client==0.57.0 # via docker websocket-client==0.57.0 # via docker
werkzeug==1.0.1 # via flask, moto, pytest-flask werkzeug==1.0.1 # via flask, moto, pytest-flask
wrapt==1.12.1 # via aws-xray-sdk wrapt==1.12.1 # via aws-xray-sdk
xmltodict==0.12.0 # via moto xmltodict==0.12.0 # via moto
zipp==3.1.0 # via importlib-metadata zipp==3.1.0 # via importlib-metadata, moto
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# setuptools # setuptools

View File

@ -4,19 +4,19 @@
# #
# pip-compile --no-index --output-file=requirements.txt requirements.in # pip-compile --no-index --output-file=requirements.txt requirements.in
# #
acme==1.7.0 # via -r requirements.in acme==1.9.0 # via -r requirements.in
alembic-autogenerate-enums==0.0.2 # via -r requirements.in alembic-autogenerate-enums==0.0.2 # via -r requirements.in
alembic==1.4.2 # via flask-migrate alembic==1.4.2 # via flask-migrate
amqp==2.5.2 # via kombu amqp==2.5.2 # via kombu
aniso8601==8.0.0 # via flask-restful aniso8601==8.0.0 # via flask-restful
arrow==0.16.0 # via -r requirements.in arrow==0.17.0 # via -r requirements.in
asyncpool==1.0 # via -r requirements.in asyncpool==1.0 # via -r requirements.in
bcrypt==3.1.7 # via flask-bcrypt, paramiko bcrypt==3.1.7 # via flask-bcrypt, paramiko
beautifulsoup4==4.9.1 # via cloudflare beautifulsoup4==4.9.1 # via cloudflare
billiard==3.6.3.0 # via celery billiard==3.6.3.0 # via celery
blinker==1.4 # via flask-mail, flask-principal, raven blinker==1.4 # via flask-mail, flask-principal, raven
boto3==1.14.48 # via -r requirements.in boto3==1.16.5 # via -r requirements.in
botocore==1.17.48 # via -r requirements.in, boto3, s3transfer botocore==1.19.5 # via -r requirements.in, boto3, s3transfer
celery[redis]==4.4.2 # via -r requirements.in celery[redis]==4.4.2 # via -r requirements.in
certifi==2020.6.20 # via -r requirements.in, requests certifi==2020.6.20 # via -r requirements.in, requests
certsrv==2.1.1 # via -r requirements.in certsrv==2.1.1 # via -r requirements.in
@ -24,13 +24,12 @@ cffi==1.14.0 # via bcrypt, cryptography, pynacl
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
click==7.1.1 # via flask click==7.1.1 # via flask
cloudflare==2.8.13 # via -r requirements.in cloudflare==2.8.13 # via -r requirements.in
cryptography==3.0 # via -r requirements.in, acme, josepy, paramiko, pyopenssl, requests cryptography==3.2 # via -r requirements.in, acme, josepy, paramiko, pyopenssl, requests
dnspython3==1.15.0 # via -r requirements.in dnspython3==1.15.0 # via -r requirements.in
dnspython==1.15.0 # via dnspython3 dnspython==1.15.0 # via dnspython3
docutils==0.15.2 # via botocore
dyn==1.8.1 # via -r requirements.in dyn==1.8.1 # via -r requirements.in
flask-bcrypt==0.7.1 # via -r requirements.in flask-bcrypt==0.7.1 # via -r requirements.in
flask-cors==3.0.8 # via -r requirements.in flask-cors==3.0.9 # via -r requirements.in
flask-mail==0.9.1 # via -r requirements.in flask-mail==0.9.1 # via -r requirements.in
flask-migrate==2.5.3 # via -r requirements.in flask-migrate==2.5.3 # via -r requirements.in
flask-principal==0.4.0 # via -r requirements.in flask-principal==0.4.0 # via -r requirements.in
@ -58,9 +57,9 @@ markupsafe==1.1.1 # via jinja2, mako
marshmallow-sqlalchemy==0.23.1 # via -r requirements.in marshmallow-sqlalchemy==0.23.1 # via -r requirements.in
marshmallow==2.20.4 # via -r requirements.in, marshmallow-sqlalchemy marshmallow==2.20.4 # via -r requirements.in, marshmallow-sqlalchemy
ndg-httpsclient==0.5.1 # via -r requirements.in ndg-httpsclient==0.5.1 # via -r requirements.in
paramiko==2.7.1 # via -r requirements.in paramiko==2.7.2 # via -r requirements.in
pem==20.1.0 # via -r requirements.in pem==20.1.0 # via -r requirements.in
psycopg2==2.8.5 # via -r requirements.in psycopg2==2.8.6 # via -r requirements.in
pyasn1-modules==0.2.8 # via pyjks, python-ldap pyasn1-modules==0.2.8 # via pyjks, python-ldap
pyasn1==0.4.8 # via ndg-httpsclient, pyasn1-modules, pyjks, python-ldap pyasn1==0.4.8 # via ndg-httpsclient, pyasn1-modules, pyjks, python-ldap
pycparser==2.20 # via cffi pycparser==2.20 # via cffi

View File

@ -9,30 +9,18 @@ Is a TLS management and orchestration tool.
""" """
from __future__ import absolute_import from __future__ import absolute_import
import sys
import json
import os.path
import datetime import datetime
import json
import logging
import os.path
import sys
from subprocess import check_output
from distutils import log from setuptools import Command
from distutils.core import Command from setuptools import setup, find_packages
from setuptools.command.develop import develop from setuptools.command.develop import develop
from setuptools.command.install import install from setuptools.command.install import install
from setuptools.command.sdist import sdist from setuptools.command.sdist import sdist
from setuptools import setup, find_packages
from subprocess import check_output
import pip
if tuple(map(int, pip.__version__.split('.'))) >= (19, 3, 0):
from pip._internal.network.session import PipSession
from pip._internal.req import parse_requirements
elif tuple(map(int, pip.__version__.split('.'))) >= (10, 0, 0):
from pip._internal.download import PipSession
from pip._internal.req import parse_requirements
else:
from pip.download import PipSession
from pip.req import parse_requirements
ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__))) ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__)))
@ -44,21 +32,18 @@ about = {}
with open(os.path.join(ROOT, 'lemur', '__about__.py')) as f: with open(os.path.join(ROOT, 'lemur', '__about__.py')) as f:
exec(f.read(), about) # nosec: about file is benign exec(f.read(), about) # nosec: about file is benign
install_requires_g = parse_requirements("requirements.txt", session=PipSession()) # Parse requirements files
tests_require_g = parse_requirements("requirements-tests.txt", session=PipSession()) with open('requirements.txt') as f:
docs_require_g = parse_requirements("requirements-docs.txt", session=PipSession()) install_requirements = f.read().splitlines()
dev_requires_g = parse_requirements("requirements-dev.txt", session=PipSession())
if tuple(map(int, pip.__version__.split('.'))) >= (20, 1): with open('requirements-tests.txt') as f:
install_requires = [str(ir.requirement) for ir in install_requires_g] tests_requirements = f.read().splitlines()
tests_require = [str(ir.requirement) for ir in tests_require_g]
docs_require = [str(ir.requirement) for ir in docs_require_g] with open('requirements-docs.txt') as f:
dev_requires = [str(ir.requirement) for ir in dev_requires_g] docs_requirements = f.read().splitlines()
else:
install_requires = [str(ir.req) for ir in install_requires_g] with open('requirements-dev.txt') as f:
tests_require = [str(ir.req) for ir in tests_require_g] dev_requirements = f.read().splitlines()
docs_require = [str(ir.req) for ir in docs_require_g]
dev_requires = [str(ir.req) for ir in dev_requires_g]
class SmartInstall(install): class SmartInstall(install):
@ -67,6 +52,7 @@ class SmartInstall(install):
If the package indicator is missing, this will also force a run of If the package indicator is missing, this will also force a run of
`build_static` which is required for JavaScript assets and other things. `build_static` which is required for JavaScript assets and other things.
""" """
def _needs_static(self): def _needs_static(self):
return not os.path.exists(os.path.join(ROOT, 'lemur/static/dist')) return not os.path.exists(os.path.join(ROOT, 'lemur/static/dist'))
@ -105,16 +91,16 @@ class BuildStatic(Command):
pass pass
def run(self): def run(self):
log.info("running [npm install --quiet] in {0}".format(ROOT)) logging.info("running [npm install --quiet] in {0}".format(ROOT))
try: try:
check_output(['npm', 'install', '--quiet'], cwd=ROOT) check_output(['npm', 'install', '--quiet'], cwd=ROOT)
log.info("running [gulp build]") logging.info("running [gulp build]")
check_output([os.path.join(ROOT, 'node_modules', '.bin', 'gulp'), 'build'], cwd=ROOT) check_output([os.path.join(ROOT, 'node_modules', '.bin', 'gulp'), 'build'], cwd=ROOT)
log.info("running [gulp package]") logging.info("running [gulp package]")
check_output([os.path.join(ROOT, 'node_modules', '.bin', 'gulp'), 'package'], cwd=ROOT) check_output([os.path.join(ROOT, 'node_modules', '.bin', 'gulp'), 'package'], cwd=ROOT)
except Exception as e: except Exception as e:
log.warn("Unable to build static content") logging.warn("Unable to build static content")
setup( setup(
@ -128,11 +114,11 @@ setup(
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,
install_requires=install_requires, install_requires=install_requirements,
extras_require={ extras_require={
'tests': tests_require, 'tests': tests_requirements,
'docs': docs_require, 'docs': docs_requirements,
'dev': dev_requires, 'dev': dev_requirements,
}, },
cmdclass={ cmdclass={
'build_static': BuildStatic, 'build_static': BuildStatic,
@ -149,6 +135,7 @@ setup(
'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin', 'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin',
'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin', 'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin',
'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin', 'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin',
'aws_sns = lemur.plugins.lemur_aws.plugin:SNSNotificationPlugin',
'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin', 'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',
'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin', 'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin',
'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin', 'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin',
@ -167,7 +154,9 @@ setup(
'vault_source = lemur.plugins.lemur_vault_dest.plugin:VaultSourcePlugin', 'vault_source = lemur.plugins.lemur_vault_dest.plugin:VaultSourcePlugin',
'vault_desination = lemur.plugins.lemur_vault_dest.plugin:VaultDestinationPlugin', 'vault_desination = lemur.plugins.lemur_vault_dest.plugin:VaultDestinationPlugin',
'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin', 'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin',
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin' 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin',
'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin',
'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin'
], ],
}, },
classifiers=[ classifiers=[