commit
31bfcd8810
|
@ -1,6 +1,87 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
0.8.0 - `2020-11-13`
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This release comes after more than two years and contains many interesting new features and improvements.
|
||||||
|
In addition to multiple new plugins, such as ACME-http01, ADCS, PowerDNS, UltraDNS, Entrust, SNS, many of Lemur's existing
|
||||||
|
flows have improved.
|
||||||
|
|
||||||
|
In the future, we plan to do frequent releases.
|
||||||
|
|
||||||
|
|
||||||
|
Summary of notable changes:
|
||||||
|
|
||||||
|
- AWS S3 plugin: added delete, get methods, and support for uploading/deleting acme tokens
|
||||||
|
- ACME plugin:
|
||||||
|
- revamp of the plugin
|
||||||
|
- support for http01 domain validation, via S3 and SFTP as destination for the acme token
|
||||||
|
- support for CNAME delegated domain validation
|
||||||
|
- store-acme-account-details
|
||||||
|
- PowerDNS plugin
|
||||||
|
- UltraDNS plugin
|
||||||
|
- ADCS plugin
|
||||||
|
- SNS plugin
|
||||||
|
- Entrust plugin
|
||||||
|
- Rotation:
|
||||||
|
- respecting keyType and extensions
|
||||||
|
- region-by-region rotation option
|
||||||
|
- default to auto-rotate when cert attached to endpoint
|
||||||
|
- default to 1y validity during rotation for multi-year browser-trusted certs
|
||||||
|
- Certificate: search_by_name, and important performance improvements
|
||||||
|
- UI
|
||||||
|
- reducing the EC curve options to the relevant ones
|
||||||
|
- edit option for notifications, destinations and sources
|
||||||
|
- showing 13 month validity as default
|
||||||
|
- option to hide certs expired since 3month
|
||||||
|
- faster Permalink (no search involved)
|
||||||
|
- commonName Auto Added as DNS in the UI
|
||||||
|
- improved search and cert lookup
|
||||||
|
- celery tasks instead of crone, for better logging and monitoring
|
||||||
|
- countless bugfixes
|
||||||
|
- group-lookup-fix-referral
|
||||||
|
- url_context_path
|
||||||
|
- duplicate notification
|
||||||
|
- digicert-time-bug-fix
|
||||||
|
- improved-csr-support
|
||||||
|
- fix-cryptography-intermediate-ca
|
||||||
|
- enhanced logging
|
||||||
|
- vault-k8s-auth
|
||||||
|
- cfssl-key-fix
|
||||||
|
- cert-sync-endpoint-find-by-hash
|
||||||
|
- nlb-naming-bug
|
||||||
|
- fix_vault_api_v2_append
|
||||||
|
- aid_openid_roles_provider_integration
|
||||||
|
- rewrite-java-keystore-use-pyjks
|
||||||
|
- vault_kv2
|
||||||
|
|
||||||
|
|
||||||
|
To see the full list of changes, you can run
|
||||||
|
|
||||||
|
$ git log --merges --first-parent master --pretty=format:"%h %<(10,trunc)%aN %C(white)%<(15)%ar%Creset %C(red bold)%<(15)%D%Creset %s" | grep -v "depend"
|
||||||
|
|
||||||
|
|
||||||
|
Special thanks to all who contributed to this release, notably:
|
||||||
|
|
||||||
|
- `peschmae <https://github.com/peschmae>`_
|
||||||
|
- `sirferl <https://github.com/sirferl>`_
|
||||||
|
- `lukasmrtvy <https://github.com/lukasmrtvy>`_
|
||||||
|
- `intgr <https://github.com/intgr>`_
|
||||||
|
- `kush-bavishi <https://github.com/kush-bavishi>`_
|
||||||
|
- `alwaysjolley <https://github.com/alwaysjolley>`_
|
||||||
|
- `jplana <https://github.com/jplana>`_
|
||||||
|
- `explody <https://github.com/explody>`_
|
||||||
|
- `titouanc <https://github.com/titouanc>`_
|
||||||
|
- `jramosf <https://github.com/jramosf>`_
|
||||||
|
|
||||||
|
|
||||||
|
Upgrading
|
||||||
|
---------
|
||||||
|
|
||||||
|
.. note:: This release will need a migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
0.7 - `2018-05-07`
|
0.7 - `2018-05-07`
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -11,22 +11,47 @@ software.
|
||||||
|
|
||||||
* Update the version number in ``lemur/__about__.py``.
|
* Update the version number in ``lemur/__about__.py``.
|
||||||
* Set the release date in the :doc:`/changelog`.
|
* Set the release date in the :doc:`/changelog`.
|
||||||
* Do a commit indicating this.
|
* Do a commit indicating this, and raise a pull request with this.
|
||||||
* Send a pull request with this.
|
|
||||||
* Wait for it to be merged.
|
* Wait for it to be merged.
|
||||||
|
|
||||||
Performing the release
|
Performing the release
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
The commit that merged the version number bump is now the official release
|
The commit that merged the version number bump is now the official release
|
||||||
commit for this release. You will need to have ``gpg`` installed and a ``gpg``
|
commit for this release. You need an `API key <https://pypi.org/manage/account/#api-tokens>`_,
|
||||||
key in order to do a release. Once this has happened:
|
which requires permissions to maintain the Lemur `project <https://pypi.org/project/lemur/>`_.
|
||||||
|
|
||||||
* Run ``invoke release {version}``.
|
For creating the release, follow these steps (more details `here <https://packaging.python.org/tutorials/packaging-projects/#generating-distribution-archives>`_)
|
||||||
|
|
||||||
The release should now be available on PyPI and a tag should be available in
|
* Make sure you have the latest versions of setuptools and wheel installed:
|
||||||
|
|
||||||
|
``python3 -m pip install --user --upgrade setuptools wheel``
|
||||||
|
|
||||||
|
* Now run this command from the same directory where setup.py is located:
|
||||||
|
|
||||||
|
``python3 setup.py sdist bdist_wheel``
|
||||||
|
|
||||||
|
* Once completed it should generate two files in the dist directory:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
$ ls dist/
|
||||||
|
lemur-0.8.0-py2.py3-none-any.whl lemur-0.8.0.tar.gz
|
||||||
|
|
||||||
|
|
||||||
|
* In this step, the distribution will be uploaded. You’ll need to install Twine:
|
||||||
|
|
||||||
|
``python3 -m pip install --user --upgrade twine``
|
||||||
|
|
||||||
|
* Once installed, run Twine to upload all of the archives under dist. Once installed, run Twine to upload all of the archives under dist:
|
||||||
|
|
||||||
|
``python3 -m twine upload --repository pypi dist/*``
|
||||||
|
|
||||||
|
The release should now be available on `PyPI Lemur <https://pypi.org/project/lemur/>`_ and a tag should be available in
|
||||||
the repository.
|
the repository.
|
||||||
|
|
||||||
|
Make sure to also make a github `release <https://github.com/Netflix/lemur/releases>`_ which will pick up the latest version.
|
||||||
|
|
||||||
Verifying the release
|
Verifying the release
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
|
|
|
@ -415,8 +415,8 @@ And the worker can be started with desired options such as the following::
|
||||||
|
|
||||||
supervisor or systemd configurations should be created for these in production environments as appropriate.
|
supervisor or systemd configurations should be created for these in production environments as appropriate.
|
||||||
|
|
||||||
Add support for LetsEncrypt
|
Add support for LetsEncrypt/ACME
|
||||||
===========================
|
================================
|
||||||
|
|
||||||
LetsEncrypt is a free, limited-feature certificate authority that offers publicly trusted certificates that are valid
|
LetsEncrypt is a free, limited-feature certificate authority that offers publicly trusted certificates that are valid
|
||||||
for 90 days. LetsEncrypt does not use organizational validation (OV), and instead relies on domain validation (DV).
|
for 90 days. LetsEncrypt does not use organizational validation (OV), and instead relies on domain validation (DV).
|
||||||
|
@ -424,7 +424,10 @@ LetsEncrypt requires that we prove ownership of a domain before we're able to is
|
||||||
time we want a certificate.
|
time we want a certificate.
|
||||||
|
|
||||||
The most common methods to prove ownership are HTTP validation and DNS validation. Lemur supports DNS validation
|
The most common methods to prove ownership are HTTP validation and DNS validation. Lemur supports DNS validation
|
||||||
through the creation of DNS TXT records.
|
through the creation of DNS TXT records as well as HTTP validation, reusing the destination concept.
|
||||||
|
|
||||||
|
ACME DNS Challenge
|
||||||
|
------------------
|
||||||
|
|
||||||
In a nutshell, when we send a certificate request to LetsEncrypt, they generate a random token and ask us to put that
|
In a nutshell, when we send a certificate request to LetsEncrypt, they generate a random token and ask us to put that
|
||||||
token in a DNS text record to prove ownership of a domain. If a certificate request has multiple domains, we must
|
token in a DNS text record to prove ownership of a domain. If a certificate request has multiple domains, we must
|
||||||
|
@ -462,6 +465,24 @@ possible. To enable this functionality, periodically (or through Cron/Celery) ru
|
||||||
This command will traverse all DNS providers, determine which zones they control, and upload this list of zones to
|
This command will traverse all DNS providers, determine which zones they control, and upload this list of zones to
|
||||||
Lemur's database (in the dns_providers table). Alternatively, you can manually input this data.
|
Lemur's database (in the dns_providers table). Alternatively, you can manually input this data.
|
||||||
|
|
||||||
|
ACME HTTP Challenge
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The flow for requesting a certificate using the HTTP challenge is not that different from the one described for the DNS
|
||||||
|
challenge. The only difference is, that instead of creating a DNS TXT record, a file is uploaded to a Webserver which
|
||||||
|
serves the file at `http://<domain>/.well-known/acme-challenge/<token>`
|
||||||
|
|
||||||
|
Currently the HTTP challenge also works without Celery, since it's done while creating the certificate, and doesn't
|
||||||
|
rely on celery to create the DNS record. This will change when we implement mix & match of acme challenge types.
|
||||||
|
|
||||||
|
To create a HTTP compatible Authority, you first need to create a new destination that will be used to deploy the
|
||||||
|
challenge token. Visit `Admin` -> `Destination` and click `Create`. The path you provide for the destination needs to
|
||||||
|
be the exact path that is called when the ACME providers calls ``http://<domain>/.well-known/acme-challenge/`. The
|
||||||
|
token part will be added dynamically by the acme_upload.
|
||||||
|
Currently only the SFTP and S3 Bucket destination support the ACME HTTP challenge.
|
||||||
|
|
||||||
|
Afterwards you can create a new certificate authority as described in the DNS challenge, but need to choose
|
||||||
|
`Acme HTTP-01` as the plugin type, and then the destination you created beforehand.
|
||||||
|
|
||||||
LetsEncrypt: pinning to cross-signed ICA
|
LetsEncrypt: pinning to cross-signed ICA
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
|
@ -15,7 +15,7 @@ __title__ = "lemur"
|
||||||
__summary__ = "Certificate management and orchestration service"
|
__summary__ = "Certificate management and orchestration service"
|
||||||
__uri__ = "https://github.com/Netflix/lemur"
|
__uri__ = "https://github.com/Netflix/lemur"
|
||||||
|
|
||||||
__version__ = "0.7.0"
|
__version__ = "0.8.0"
|
||||||
|
|
||||||
__author__ = "The Lemur developers"
|
__author__ = "The Lemur developers"
|
||||||
__email__ = "security@netflix.com"
|
__email__ = "security@netflix.com"
|
||||||
|
|
|
@ -103,8 +103,9 @@ def send_plugin_notification(event_type, data, recipients, notification):
|
||||||
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
||||||
log_data = {
|
log_data = {
|
||||||
"function": function,
|
"function": function,
|
||||||
"message": f"Sending expiration notification for to recipients {recipients}",
|
"message": f"Sending {event_type} notification for to recipients {recipients}",
|
||||||
"notification_type": "expiration",
|
"notification_type": event_type,
|
||||||
|
"notification_plugin": notification.plugin.slug,
|
||||||
"certificate_targets": recipients,
|
"certificate_targets": recipients,
|
||||||
}
|
}
|
||||||
status = FAILURE_METRIC_STATUS
|
status = FAILURE_METRIC_STATUS
|
||||||
|
@ -121,7 +122,7 @@ def send_plugin_notification(event_type, data, recipients, notification):
|
||||||
"notification",
|
"notification",
|
||||||
"counter",
|
"counter",
|
||||||
1,
|
1,
|
||||||
metric_tags={"status": status, "event_type": event_type},
|
metric_tags={"status": status, "event_type": event_type, "plugin": notification.plugin.slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
if status == SUCCESS_METRIC_STATUS:
|
if status == SUCCESS_METRIC_STATUS:
|
||||||
|
@ -142,7 +143,6 @@ def send_expiration_notifications(exclude):
|
||||||
|
|
||||||
for notification_label, certificates in notification_group.items():
|
for notification_label, certificates in notification_group.items():
|
||||||
notification_data = []
|
notification_data = []
|
||||||
security_data = []
|
|
||||||
|
|
||||||
notification = certificates[0][0]
|
notification = certificates[0][0]
|
||||||
|
|
||||||
|
@ -152,33 +152,26 @@ def send_expiration_notifications(exclude):
|
||||||
certificate
|
certificate
|
||||||
).data
|
).data
|
||||||
notification_data.append(cert_data)
|
notification_data.append(cert_data)
|
||||||
security_data.append(cert_data)
|
|
||||||
|
|
||||||
if send_default_notification(
|
|
||||||
"expiration", notification_data, [owner], notification.options
|
|
||||||
):
|
|
||||||
success += 1
|
|
||||||
else:
|
|
||||||
failure += 1
|
|
||||||
|
|
||||||
recipients = notification.plugin.filter_recipients(notification.options, security_email + [owner])
|
|
||||||
|
|
||||||
|
email_recipients = notification.plugin.get_recipients(notification.options, security_email + [owner])
|
||||||
|
# Plugin will ONLY use the provided recipients if it's email; any other notification plugin ignores them
|
||||||
if send_plugin_notification(
|
if send_plugin_notification(
|
||||||
"expiration",
|
"expiration", notification_data, email_recipients, notification
|
||||||
notification_data,
|
|
||||||
recipients,
|
|
||||||
notification,
|
|
||||||
):
|
):
|
||||||
success += 1
|
success += len(email_recipients)
|
||||||
else:
|
else:
|
||||||
failure += 1
|
failure += len(email_recipients)
|
||||||
|
# If we're using an email plugin, we're done,
|
||||||
if send_default_notification(
|
# since "security_email + [owner]" were added as email_recipients.
|
||||||
"expiration", security_data, security_email, notification.options
|
# If we're not using an email plugin, we also need to send an email to the security team and owner,
|
||||||
):
|
# since the plugin notification didn't send anything to them.
|
||||||
success += 1
|
if notification.plugin.slug != "email-notification":
|
||||||
else:
|
if send_default_notification(
|
||||||
failure += 1
|
"expiration", notification_data, email_recipients, notification.options
|
||||||
|
):
|
||||||
|
success = 1 + len(email_recipients)
|
||||||
|
else:
|
||||||
|
failure = 1 + len(email_recipients)
|
||||||
|
|
||||||
return success, failure
|
return success, failure
|
||||||
|
|
||||||
|
@ -195,15 +188,16 @@ def send_default_notification(notification_type, data, targets, notification_opt
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
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
|
||||||
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", "email-notification")
|
||||||
)
|
)
|
||||||
|
log_data = {
|
||||||
|
"function": function,
|
||||||
|
"message": f"Sending {notification_type} notification for certificate data {data} to targets {targets}",
|
||||||
|
"notification_type": notification_type,
|
||||||
|
"notification_plugin": notification_plugin.slug,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.logger.debug(log_data)
|
current_app.logger.debug(log_data)
|
||||||
|
@ -212,7 +206,7 @@ def send_default_notification(notification_type, data, targets, notification_opt
|
||||||
status = SUCCESS_METRIC_STATUS
|
status = SUCCESS_METRIC_STATUS
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_data["message"] = f"Unable to send {notification_type} notification for certificate data {data} " \
|
log_data["message"] = f"Unable to send {notification_type} notification for certificate data {data} " \
|
||||||
f"to target {targets}"
|
f"to targets {targets}"
|
||||||
current_app.logger.error(log_data, exc_info=True)
|
current_app.logger.error(log_data, exc_info=True)
|
||||||
sentry.captureException()
|
sentry.captureException()
|
||||||
|
|
||||||
|
@ -220,7 +214,7 @@ def send_default_notification(notification_type, data, targets, notification_opt
|
||||||
"notification",
|
"notification",
|
||||||
"counter",
|
"counter",
|
||||||
1,
|
1,
|
||||||
metric_tags={"status": status, "event_type": notification_type},
|
metric_tags={"status": status, "event_type": notification_type, "plugin": notification_plugin.slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
if status == SUCCESS_METRIC_STATUS:
|
if status == SUCCESS_METRIC_STATUS:
|
||||||
|
@ -247,15 +241,14 @@ def send_pending_failure_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
|
email_recipients = []
|
||||||
if notify_owner:
|
if notify_owner:
|
||||||
notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert)
|
email_recipients = email_recipients + [data["owner"]]
|
||||||
|
|
||||||
notify_security_success = False
|
|
||||||
if notify_security:
|
if notify_security:
|
||||||
notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert)
|
email_recipients = email_recipients + data["security_email"]
|
||||||
|
|
||||||
return notify_owner_success or notify_security_success
|
return send_default_notification("failed", data, email_recipients, pending_cert)
|
||||||
|
|
||||||
|
|
||||||
def needs_notification(certificate):
|
def needs_notification(certificate):
|
||||||
|
|
|
@ -20,14 +20,14 @@ 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):
|
def get_recipients(self, options, additional_recipients):
|
||||||
"""
|
"""
|
||||||
Given a set of options (which should include configured recipient info), filters out recipients that
|
Given a set of options (which should include configured recipient info), returns the parsed list of recipients
|
||||||
we do NOT want to notify.
|
from those options plus the additional recipients specified. The returned value has no duplicates.
|
||||||
|
|
||||||
For any notification types where recipients can't be dynamically modified, this returns an empty list.
|
For any notification types where recipients can't be dynamically modified, this returns only the additional recipients.
|
||||||
"""
|
"""
|
||||||
return []
|
return additional_recipients
|
||||||
|
|
||||||
|
|
||||||
class ExpirationNotificationPlugin(NotificationPlugin):
|
class ExpirationNotificationPlugin(NotificationPlugin):
|
||||||
|
|
|
@ -224,7 +224,7 @@ class AcmeHandler(object):
|
||||||
def revoke_certificate(self, certificate):
|
def revoke_certificate(self, certificate):
|
||||||
if not self.reuse_account(certificate.authority):
|
if not self.reuse_account(certificate.authority):
|
||||||
raise InvalidConfiguration("There is no ACME account saved, unable to revoke the certificate.")
|
raise InvalidConfiguration("There is no ACME account saved, unable to revoke the certificate.")
|
||||||
acme_client, _ = self.acme.setup_acme_client(certificate.authority)
|
acme_client, _ = self.setup_acme_client(certificate.authority)
|
||||||
|
|
||||||
fullchain_com = jose.ComparableX509(
|
fullchain_com = jose.ComparableX509(
|
||||||
OpenSSL.crypto.load_certificate(
|
OpenSSL.crypto.load_certificate(
|
||||||
|
|
|
@ -419,7 +419,7 @@ class S3DestinationPlugin(ExportDestinationPlugin):
|
||||||
:param kwargs:
|
:param kwargs:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
current_app.logger.debug("S3 destination plugin is started for HTTP-01 challenge")
|
current_app.logger.debug("S3 destination plugin is started to upload HTTP-01 challenge")
|
||||||
|
|
||||||
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
||||||
|
|
||||||
|
@ -431,16 +431,16 @@ class S3DestinationPlugin(ExportDestinationPlugin):
|
||||||
if not prefix.endswith("/"):
|
if not prefix.endswith("/"):
|
||||||
prefix + "/"
|
prefix + "/"
|
||||||
|
|
||||||
res = s3.put(bucket_name=bucket_name,
|
response = s3.put(bucket_name=bucket_name,
|
||||||
region_name=region,
|
region_name=region,
|
||||||
prefix=prefix + filename,
|
prefix=prefix + filename,
|
||||||
data=token,
|
data=token,
|
||||||
encrypt=False,
|
encrypt=False,
|
||||||
account_number=account_number)
|
account_number=account_number)
|
||||||
res = "Success" if res else "Failure"
|
res = "Success" if response else "Failure"
|
||||||
log_data = {
|
log_data = {
|
||||||
"function": function,
|
"function": function,
|
||||||
"message": "check if any valid certificate is revoked",
|
"message": "upload acme token challenge",
|
||||||
"result": res,
|
"result": res,
|
||||||
"bucket_name": bucket_name,
|
"bucket_name": bucket_name,
|
||||||
"filename": filename
|
"filename": filename
|
||||||
|
@ -449,6 +449,34 @@ class S3DestinationPlugin(ExportDestinationPlugin):
|
||||||
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
|
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
|
||||||
"bucket_name": bucket_name,
|
"bucket_name": bucket_name,
|
||||||
"filename": filename})
|
"filename": filename})
|
||||||
|
return response
|
||||||
|
|
||||||
|
def delete_acme_token(self, token_path, options, **kwargs):
|
||||||
|
|
||||||
|
current_app.logger.debug("S3 destination plugin is started to delete HTTP-01 challenge")
|
||||||
|
|
||||||
|
function = f"{__name__}.{sys._getframe().f_code.co_name}"
|
||||||
|
|
||||||
|
account_number = self.get_option("accountNumber", options)
|
||||||
|
bucket_name = self.get_option("bucket", options)
|
||||||
|
prefix = self.get_option("prefix", options)
|
||||||
|
filename = token_path.split("/")[-1]
|
||||||
|
response = s3.delete(bucket_name=bucket_name,
|
||||||
|
prefixed_object_name=prefix + filename,
|
||||||
|
account_number=account_number)
|
||||||
|
res = "Success" if response else "Failure"
|
||||||
|
log_data = {
|
||||||
|
"function": function,
|
||||||
|
"message": "delete acme token challenge",
|
||||||
|
"result": res,
|
||||||
|
"bucket_name": bucket_name,
|
||||||
|
"filename": filename
|
||||||
|
}
|
||||||
|
current_app.logger.info(log_data)
|
||||||
|
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
|
||||||
|
"bucket_name": bucket_name,
|
||||||
|
"filename": filename})
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class SNSNotificationPlugin(ExpirationNotificationPlugin):
|
class SNSNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
|
|
|
@ -68,10 +68,11 @@ def test_upload_acme_token(app):
|
||||||
s3_client.create_bucket(Bucket=bucket)
|
s3_client.create_bucket(Bucket=bucket)
|
||||||
p = plugins.get("aws-s3")
|
p = plugins.get("aws-s3")
|
||||||
|
|
||||||
p.upload_acme_token(token_path=token_path,
|
response = p.upload_acme_token(token_path=token_path,
|
||||||
token_content=token_content,
|
token_content=token_content,
|
||||||
token=token_content,
|
token=token_content,
|
||||||
options=additional_options)
|
options=additional_options)
|
||||||
|
assert response
|
||||||
|
|
||||||
response = get(bucket_name=bucket,
|
response = get(bucket_name=bucket,
|
||||||
prefixed_object_name=prefix + token_name,
|
prefixed_object_name=prefix + token_name,
|
||||||
|
@ -80,3 +81,8 @@ def test_upload_acme_token(app):
|
||||||
|
|
||||||
# put data, and getting the same data
|
# put data, and getting the same data
|
||||||
assert (response == token_content)
|
assert (response == token_content)
|
||||||
|
|
||||||
|
response = p.delete_acme_token(token_path=token_path,
|
||||||
|
options=additional_options,
|
||||||
|
account_number=account)
|
||||||
|
assert response
|
||||||
|
|
|
@ -105,6 +105,8 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send(notification_type, message, targets, options, **kwargs):
|
def send(notification_type, message, targets, options, **kwargs):
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
|
||||||
subject = "Lemur: {0} Notification".format(notification_type.capitalize())
|
subject = "Lemur: {0} Notification".format(notification_type.capitalize())
|
||||||
|
|
||||||
|
@ -119,11 +121,9 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
send_via_smtp(subject, body, targets)
|
send_via_smtp(subject, body, targets)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filter_recipients(options, excluded_recipients, **kwargs):
|
def get_recipients(options, additional_recipients, **kwargs):
|
||||||
notification_recipients = get_plugin_option("recipients", options)
|
notification_recipients = get_plugin_option("recipients", options)
|
||||||
if notification_recipients:
|
if notification_recipients:
|
||||||
notification_recipients = notification_recipients.split(",")
|
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
|
return list(set(notification_recipients + additional_recipients))
|
||||||
|
|
|
@ -21,7 +21,6 @@ def get_options():
|
||||||
|
|
||||||
|
|
||||||
def test_render_expiration(certificate, endpoint):
|
def test_render_expiration(certificate, endpoint):
|
||||||
|
|
||||||
new_cert = CertificateFactory()
|
new_cert = CertificateFactory()
|
||||||
new_cert.replaces.append(certificate)
|
new_cert.replaces.append(certificate)
|
||||||
|
|
||||||
|
@ -54,7 +53,7 @@ def test_send_expiration_notification():
|
||||||
certificate.notifications[0].options = get_options()
|
certificate.notifications[0].options = get_options()
|
||||||
|
|
||||||
verify_sender_email()
|
verify_sender_email()
|
||||||
assert send_expiration_notifications([]) == (3, 0) # owner, recipients (only counted as 1), and security
|
assert send_expiration_notifications([]) == (4, 0) # owner (1), recipients (2), and security (1)
|
||||||
|
|
||||||
|
|
||||||
@mock_ses
|
@mock_ses
|
||||||
|
@ -76,15 +75,20 @@ def test_send_pending_failure_notification(user, pending_certificate, async_issu
|
||||||
|
|
||||||
verify_sender_email()
|
verify_sender_email()
|
||||||
assert send_pending_failure_notification(pending_certificate)
|
assert send_pending_failure_notification(pending_certificate)
|
||||||
|
assert send_pending_failure_notification(pending_certificate, True, True)
|
||||||
|
assert send_pending_failure_notification(pending_certificate, True, False)
|
||||||
|
assert send_pending_failure_notification(pending_certificate, False, True)
|
||||||
|
assert send_pending_failure_notification(pending_certificate, False, False)
|
||||||
|
|
||||||
|
|
||||||
def test_filter_recipients(certificate, endpoint):
|
def test_get_recipients(certificate, endpoint):
|
||||||
from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin
|
from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin
|
||||||
|
|
||||||
options = [{"name": "recipients", "value": "security@example.com,bob@example.com,joe@example.com"}]
|
options = [{"name": "recipients", "value": "security@example.com,joe@example.com"}]
|
||||||
assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@example.com", "bob@example.com",
|
two_emails = sorted(["security@example.com", "joe@example.com"])
|
||||||
"joe@example.com"]
|
assert sorted(EmailNotificationPlugin.get_recipients(options, [])) == two_emails
|
||||||
assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com"]) == ["bob@example.com",
|
assert sorted(EmailNotificationPlugin.get_recipients(options, ["security@example.com"])) == two_emails
|
||||||
"joe@example.com"]
|
three_emails = sorted(["security@example.com", "bob@example.com", "joe@example.com"])
|
||||||
assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com", "bob@example.com",
|
assert sorted(EmailNotificationPlugin.get_recipients(options, ["bob@example.com"])) == three_emails
|
||||||
"joe@example.com"]) == []
|
assert sorted(EmailNotificationPlugin.get_recipients(options, ["security@example.com", "bob@example.com",
|
||||||
|
"joe@example.com"])) == three_emails
|
||||||
|
|
|
@ -45,7 +45,7 @@ def determine_end_date(end_date):
|
||||||
return end_date.format('YYYY-MM-DD')
|
return end_date.format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
|
||||||
def process_options(options):
|
def process_options(options, client_id):
|
||||||
"""
|
"""
|
||||||
Processes and maps the incoming issuer options to fields/options that
|
Processes and maps the incoming issuer options to fields/options that
|
||||||
Entrust understands
|
Entrust understands
|
||||||
|
@ -78,11 +78,37 @@ def process_options(options):
|
||||||
"eku": "SERVER_AND_CLIENT_AUTH",
|
"eku": "SERVER_AND_CLIENT_AUTH",
|
||||||
"certType": product_type,
|
"certType": product_type,
|
||||||
"certExpiryDate": validity_end,
|
"certExpiryDate": validity_end,
|
||||||
"tracking": tracking_data
|
# "keyType": "RSA", Entrust complaining about this parameter
|
||||||
|
"tracking": tracking_data,
|
||||||
|
"org": options.get("organization"),
|
||||||
|
"clientId": client_id
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_id(my_response, organization):
|
||||||
|
"""
|
||||||
|
Helper function for parsing responses from the Entrust API.
|
||||||
|
:param content:
|
||||||
|
:return: :raise Exception:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
d = json.loads(my_response.content)
|
||||||
|
except ValueError:
|
||||||
|
# catch an empty json object here
|
||||||
|
d = {'response': 'No detailed message'}
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for y in d["organizations"]:
|
||||||
|
if y["name"] == organization:
|
||||||
|
found = True
|
||||||
|
client_id = y["clientId"]
|
||||||
|
if found:
|
||||||
|
return client_id
|
||||||
|
else:
|
||||||
|
raise Exception(f"Error on Organization - Use on of the List: {d['organizations']}")
|
||||||
|
|
||||||
|
|
||||||
def handle_response(my_response):
|
def handle_response(my_response):
|
||||||
"""
|
"""
|
||||||
Helper function for parsing responses from the Entrust API.
|
Helper function for parsing responses from the Entrust API.
|
||||||
|
@ -192,9 +218,25 @@ class EntrustIssuerPlugin(IssuerPlugin):
|
||||||
}
|
}
|
||||||
current_app.logger.info(log_data)
|
current_app.logger.info(log_data)
|
||||||
|
|
||||||
|
# firstly we need the organization ID
|
||||||
|
url = current_app.config.get("ENTRUST_URL") + "/organizations"
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=(15, 40))
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise Exception("Timeout for Getting Organizations")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"Error for Getting Organization {e}")
|
||||||
|
|
||||||
|
client_id = get_client_id(response, issuer_options.get("organization"))
|
||||||
|
log_data = {
|
||||||
|
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
|
||||||
|
"message": f"Organization id: {client_id}"
|
||||||
|
}
|
||||||
|
current_app.logger.info(log_data)
|
||||||
|
|
||||||
url = current_app.config.get("ENTRUST_URL") + "/certificates"
|
url = current_app.config.get("ENTRUST_URL") + "/certificates"
|
||||||
|
|
||||||
data = process_options(issuer_options)
|
data = process_options(issuer_options, client_id)
|
||||||
data["csr"] = csr
|
data["csr"] = csr
|
||||||
|
|
||||||
response_dict = order_and_download_certificate(self.session, url, data)
|
response_dict = order_and_download_certificate(self.session, url, data)
|
||||||
|
@ -202,7 +244,7 @@ class EntrustIssuerPlugin(IssuerPlugin):
|
||||||
external_id = response_dict['trackingId']
|
external_id = response_dict['trackingId']
|
||||||
cert = response_dict['endEntityCert']
|
cert = response_dict['endEntityCert']
|
||||||
if len(response_dict['chainCerts']) < 2:
|
if len(response_dict['chainCerts']) < 2:
|
||||||
# certificate signed by CA directly, no ICA included ini the chain
|
# certificate signed by CA directly, no ICA included in the chain
|
||||||
chain = None
|
chain = None
|
||||||
else:
|
else:
|
||||||
chain = response_dict['chainCerts'][1]
|
chain = response_dict['chainCerts'][1]
|
||||||
|
|
|
@ -56,7 +56,10 @@ def test_process_options(mock_current_app, authority):
|
||||||
"requesterName": mock_current_app.config.get("ENTRUST_NAME"),
|
"requesterName": mock_current_app.config.get("ENTRUST_NAME"),
|
||||||
"requesterEmail": mock_current_app.config.get("ENTRUST_EMAIL"),
|
"requesterEmail": mock_current_app.config.get("ENTRUST_EMAIL"),
|
||||||
"requesterPhone": mock_current_app.config.get("ENTRUST_PHONE")
|
"requesterPhone": mock_current_app.config.get("ENTRUST_PHONE")
|
||||||
}
|
},
|
||||||
|
"org": "Example, Inc.",
|
||||||
|
"clientId": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
assert expected == plugin.process_options(options)
|
client_id = 1
|
||||||
|
assert expected == plugin.process_options(options, client_id)
|
||||||
|
|
|
@ -24,7 +24,7 @@ keyring==21.2.0 # via twine
|
||||||
mccabe==0.6.1 # via flake8
|
mccabe==0.6.1 # via flake8
|
||||||
nodeenv==1.5.0 # via -r requirements-dev.in, pre-commit
|
nodeenv==1.5.0 # via -r requirements-dev.in, pre-commit
|
||||||
pkginfo==1.5.0.1 # via twine
|
pkginfo==1.5.0.1 # via twine
|
||||||
pre-commit==2.8.2 # via -r requirements-dev.in
|
pre-commit==2.9.0 # via -r requirements-dev.in
|
||||||
pycodestyle==2.6.0 # via flake8
|
pycodestyle==2.6.0 # via flake8
|
||||||
pycparser==2.20 # via cffi
|
pycparser==2.20 # via cffi
|
||||||
pyflakes==2.2.0 # via flake8
|
pyflakes==2.2.0 # via flake8
|
||||||
|
@ -32,7 +32,7 @@ pygments==2.6.1 # via readme-renderer
|
||||||
pyyaml==5.3.1 # via -r requirements-dev.in, pre-commit
|
pyyaml==5.3.1 # via -r requirements-dev.in, pre-commit
|
||||||
readme-renderer==25.0 # via twine
|
readme-renderer==25.0 # via twine
|
||||||
requests-toolbelt==0.9.1 # via twine
|
requests-toolbelt==0.9.1 # via twine
|
||||||
requests==2.24.0 # via requests-toolbelt, twine
|
requests==2.25.0 # via requests-toolbelt, twine
|
||||||
rfc3986==1.4.0 # via twine
|
rfc3986==1.4.0 # via twine
|
||||||
secretstorage==3.1.2 # via keyring
|
secretstorage==3.1.2 # via keyring
|
||||||
six==1.15.0 # via bleach, cryptography, readme-renderer, virtualenv
|
six==1.15.0 # via bleach, cryptography, readme-renderer, virtualenv
|
||||||
|
|
|
@ -17,8 +17,8 @@ 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.16.14 # via -r requirements.txt
|
boto3==1.16.24 # via -r requirements.txt
|
||||||
botocore==1.19.14 # via -r requirements.txt, boto3, s3transfer
|
botocore==1.19.24 # 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.11.8 # via -r requirements.txt, requests
|
certifi==2020.11.8 # via -r requirements.txt, requests
|
||||||
certsrv==2.1.1 # via -r requirements.txt
|
certsrv==2.1.1 # via -r requirements.txt
|
||||||
|
@ -85,14 +85,14 @@ pyyaml==5.3.1 # via -r requirements.txt, cloudflare
|
||||||
raven[flask]==6.10.0 # via -r requirements.txt
|
raven[flask]==6.10.0 # via -r requirements.txt
|
||||||
redis==3.5.3 # via -r requirements.txt, celery
|
redis==3.5.3 # via -r requirements.txt, celery
|
||||||
requests-toolbelt==0.9.1 # via -r requirements.txt, acme
|
requests-toolbelt==0.9.1 # via -r requirements.txt, acme
|
||||||
requests[security]==2.24.0 # via -r requirements.txt, acme, certsrv, cloudflare, hvac, requests-toolbelt, sphinx
|
requests[security]==2.25.0 # via -r requirements.txt, acme, certsrv, cloudflare, hvac, requests-toolbelt, sphinx
|
||||||
retrying==1.3.3 # via -r requirements.txt
|
retrying==1.3.3 # via -r requirements.txt
|
||||||
s3transfer==0.3.3 # via -r requirements.txt, boto3
|
s3transfer==0.3.3 # via -r requirements.txt, boto3
|
||||||
six==1.15.0 # via -r requirements.txt, acme, bcrypt, cryptography, flask-cors, flask-restful, hvac, josepy, jsonlines, packaging, pynacl, pyopenssl, python-dateutil, retrying, sphinxcontrib-httpdomain, sqlalchemy-utils
|
six==1.15.0 # via -r requirements.txt, acme, bcrypt, cryptography, flask-cors, flask-restful, hvac, josepy, jsonlines, packaging, pynacl, pyopenssl, python-dateutil, retrying, sphinxcontrib-httpdomain, sqlalchemy-utils
|
||||||
snowballstemmer==2.0.0 # via sphinx
|
snowballstemmer==2.0.0 # via sphinx
|
||||||
soupsieve==2.0.1 # via -r requirements.txt, beautifulsoup4
|
soupsieve==2.0.1 # via -r requirements.txt, beautifulsoup4
|
||||||
sphinx-rtd-theme==0.5.0 # via -r requirements-docs.in
|
sphinx-rtd-theme==0.5.0 # via -r requirements-docs.in
|
||||||
sphinx==3.3.0 # via -r requirements-docs.in, sphinx-rtd-theme, sphinxcontrib-httpdomain
|
sphinx==3.3.1 # via -r requirements-docs.in, sphinx-rtd-theme, sphinxcontrib-httpdomain
|
||||||
sphinxcontrib-applehelp==1.0.2 # via sphinx
|
sphinxcontrib-applehelp==1.0.2 # via sphinx
|
||||||
sphinxcontrib-devhelp==1.0.2 # via sphinx
|
sphinxcontrib-devhelp==1.0.2 # via sphinx
|
||||||
sphinxcontrib-htmlhelp==1.0.3 # via sphinx
|
sphinxcontrib-htmlhelp==1.0.3 # via sphinx
|
||||||
|
|
|
@ -10,9 +10,9 @@ 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==20.8b1 # via -r requirements-tests.in
|
black==20.8b1 # via -r requirements-tests.in
|
||||||
boto3==1.16.14 # via aws-sam-translator, moto
|
boto3==1.16.24 # via aws-sam-translator, moto
|
||||||
boto==2.49.0 # via moto
|
boto==2.49.0 # via moto
|
||||||
botocore==1.19.14 # via aws-xray-sdk, boto3, moto, s3transfer
|
botocore==1.19.24 # via aws-xray-sdk, boto3, moto, s3transfer
|
||||||
certifi==2020.11.8 # via requests
|
certifi==2020.11.8 # 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
|
||||||
|
@ -24,7 +24,7 @@ decorator==4.4.2 # via networkx
|
||||||
docker==4.2.0 # via moto
|
docker==4.2.0 # via moto
|
||||||
ecdsa==0.14.1 # via moto, python-jose, sshpubkeys
|
ecdsa==0.14.1 # via moto, python-jose, sshpubkeys
|
||||||
factory-boy==3.1.0 # via -r requirements-tests.in
|
factory-boy==3.1.0 # via -r requirements-tests.in
|
||||||
faker==4.14.2 # via -r requirements-tests.in, factory-boy
|
faker==4.17.1 # via -r requirements-tests.in, factory-boy
|
||||||
fakeredis==1.4.4 # via -r requirements-tests.in
|
fakeredis==1.4.4 # via -r requirements-tests.in
|
||||||
flask==1.1.2 # via pytest-flask
|
flask==1.1.2 # via pytest-flask
|
||||||
freezegun==1.0.0 # via -r requirements-tests.in
|
freezegun==1.0.0 # via -r requirements-tests.in
|
||||||
|
@ -69,7 +69,7 @@ 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
|
||||||
regex==2020.4.4 # via black
|
regex==2020.4.4 # via black
|
||||||
requests-mock==1.8.0 # via -r requirements-tests.in
|
requests-mock==1.8.0 # via -r requirements-tests.in
|
||||||
requests==2.24.0 # via docker, moto, requests-mock, responses
|
requests==2.25.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
|
||||||
|
|
|
@ -15,8 +15,8 @@ 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.16.14 # via -r requirements.in
|
boto3==1.16.24 # via -r requirements.in
|
||||||
botocore==1.19.14 # via -r requirements.in, boto3, s3transfer
|
botocore==1.19.24 # 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.11.8 # via -r requirements.in, requests
|
certifi==2020.11.8 # via -r requirements.in, requests
|
||||||
certsrv==2.1.1 # via -r requirements.in
|
certsrv==2.1.1 # via -r requirements.in
|
||||||
|
@ -78,7 +78,7 @@ pyyaml==5.3.1 # via -r requirements.in, cloudflare
|
||||||
raven[flask]==6.10.0 # via -r requirements.in
|
raven[flask]==6.10.0 # via -r requirements.in
|
||||||
redis==3.5.3 # via -r requirements.in, celery
|
redis==3.5.3 # via -r requirements.in, celery
|
||||||
requests-toolbelt==0.9.1 # via acme
|
requests-toolbelt==0.9.1 # via acme
|
||||||
requests[security]==2.24.0 # via -r requirements.in, acme, certsrv, cloudflare, hvac, requests-toolbelt
|
requests[security]==2.25.0 # via -r requirements.in, acme, certsrv, cloudflare, hvac, requests-toolbelt
|
||||||
retrying==1.3.3 # via -r requirements.in
|
retrying==1.3.3 # via -r requirements.in
|
||||||
s3transfer==0.3.3 # via boto3
|
s3transfer==0.3.3 # via boto3
|
||||||
six==1.15.0 # via -r requirements.in, acme, bcrypt, cryptography, flask-cors, flask-restful, hvac, josepy, jsonlines, pynacl, pyopenssl, python-dateutil, retrying, sqlalchemy-utils
|
six==1.15.0 # via -r requirements.in, acme, bcrypt, cryptography, flask-cors, flask-restful, hvac, josepy, jsonlines, pynacl, pyopenssl, python-dateutil, retrying, sqlalchemy-utils
|
||||||
|
|
Loading…
Reference in New Issue