diff --git a/README.md b/README.md index 874e03f..90225a2 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This app supports the following popular secrets backends: | [AWS Systems Manager Parameter Store](https://aws.amazon.com/secrets-manager/) | [Other: Key/value pairs](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) | [AWS credentials](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html) (see Usage section below) | | [HashiCorp Vault](https://www.vaultproject.io) | [K/V Version 2](https://www.vaultproject.io/docs/secrets/kv/kv-v2)
[K/V Version 1](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v1) | [Token](https://www.vaultproject.io/docs/auth/token)
[AppRole](https://www.vaultproject.io/docs/auth/approle)
[AWS](https://www.vaultproject.io/docs/auth/aws)
[Kubernetes](https://www.vaultproject.io/docs/auth/kubernetes) | | [Delinea/Thycotic Secret Server](https://delinea.com/products/secret-server) | [Secret Server Cloud](https://github.com/DelineaXPM/python-tss-sdk#secret-server-cloud)
[Secret Server (on-prem)](https://github.com/DelineaXPM/python-tss-sdk#initializing-secretserver)| [Access Token Authorization](https://github.com/DelineaXPM/python-tss-sdk#access-token-authorization)
[Domain Authorization](https://github.com/DelineaXPM/python-tss-sdk#domain-authorization)
[Password Authorization](https://github.com/DelineaXPM/python-tss-sdk#password-authorization)
| +| [Keeper Secret Manager](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide) | [Other: Key/value pairs](https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/python-sdk#retrieve-secrets)| [One Time Access Token](https://docs.keeper.io/secrets-manager/secrets-manager/about/one-time-token) | ## Screenshots @@ -52,6 +53,49 @@ This app supports the following popular secrets backends: See the [installation documentation](https://docs.nautobot.com/projects/secrets-providers/en/latest/admin/install/) for detailed instructions on installing the Nautobot Secrets Providers app. +### Keeper Secret Manager + +The Keeper Secret Manager plugin includes two providers: + +- **`Keeper by UID`** + + This provider uses the `UID` to specify the secret that is retrieved. The `UID` is displayed in the `Record Info`. + + - Example: + + The url is: _https://keepersecurity.com/vault/#detail/OuiSc5IkgxEBnbnSyVJ5UA_ + + In this example the value for `UID` is **OuiSc5IkgxEBnbnSyVJ5UA**. + +- **`Keeper by Name/Title`** + + This provider allows to select the secret by name/title. + + - Example: + + The url is _https://keepersecurity.com/vault/#search/XXX-SW-XXX-F0_ + + In this example the value for `Title` is **XXX-SW-XXX-F0**. + +#### Configuration + +```python +PLUGINS_CONFIG = { + "nautobot_secrets_providers": { + "keeper": { # https://github.com/Keeper-Security/secrets-manager/tree/master/sdk/python + "token": os.getenv("KEEPER_TOKEN", None), + }, + } +} +``` +- `name` - (optional with uid) The secret name/title. _e.g.'XXX-SW-XXX-F0'_ +- `uid` - (optional with name) The secret uid. _e.g.'OuiSc5IkgxEBnbnSyVJ5UA'_ +- `token` - (optional with config) The Keeper Secret Manager token. _e.g.'1234'_ +- `config` - (optional with token) The Keeper Secret Manager config. _e.g.'JSON'_ +- `type` - (required) The info type to retrieve from the secret (ENUM CHOICE `password` / `username`). _e.g.'password'_ + +Either one of `uid` or `name` must be specified. If `token` is not specified, it will use JSON config specified either in the form or in config file instead. If none of them are specified, it will fail to connect to Keeper Secret Manager. + ## Contributing Pull requests are welcomed and automatically built and tested against multiple version of Python and multiple version of Nautobot through GitHub Actions. diff --git a/nautobot_secrets_providers/providers/__init__.py b/nautobot_secrets_providers/providers/__init__.py index 3a0baf9..55490e3 100644 --- a/nautobot_secrets_providers/providers/__init__.py +++ b/nautobot_secrets_providers/providers/__init__.py @@ -3,6 +3,7 @@ from .aws import AWSSecretsManagerSecretsProvider, AWSSystemsManagerParameterStore from .hashicorp import HashiCorpVaultSecretsProvider from .delinea import ThycoticSecretServerSecretsProviderId, ThycoticSecretServerSecretsProviderPath +from .keeper import KeeperSecretsProvider __all__ = ( # type: ignore AWSSecretsManagerSecretsProvider, # pylint: disable=invalid-all-object @@ -10,4 +11,5 @@ HashiCorpVaultSecretsProvider, # pylint: disable=invalid-all-object ThycoticSecretServerSecretsProviderId, # pylint: disable=invalid-all-object ThycoticSecretServerSecretsProviderPath, # pylint: disable=invalid-all-object + KeeperSecretsProvider, # pylint: disable=invalid-all-object ) diff --git a/nautobot_secrets_providers/providers/choices.py b/nautobot_secrets_providers/providers/choices.py index f1bf2b9..80846e6 100644 --- a/nautobot_secrets_providers/providers/choices.py +++ b/nautobot_secrets_providers/providers/choices.py @@ -1,4 +1,4 @@ -"""Choices for Thycotic Secret Server Plugin.""" +"""Choices for providers.""" from nautobot.core.choices import ChoiceSet @@ -30,3 +30,15 @@ class HashicorpKVVersionChoices(ChoiceSet): (KV_VERSION_1, "V1"), (KV_VERSION_2, "V2"), ) + + +class KeeperTypeChoices(ChoiceSet): + """Choices for Keeper type.""" + + PASSWORD = "password" + USERNAME = "username" + + CHOICES = ( + (PASSWORD, "password"), # nosec B105 + (USERNAME, "username"), + ) diff --git a/nautobot_secrets_providers/providers/keeper.py b/nautobot_secrets_providers/providers/keeper.py new file mode 100644 index 0000000..cbf6d4d --- /dev/null +++ b/nautobot_secrets_providers/providers/keeper.py @@ -0,0 +1,196 @@ +"""Secrets Provider for Keeper.""" +import os + +# from pathlib import Path +# import base64 +# import json + +try: + from keeper_secrets_manager_core import SecretsManager + from keeper_secrets_manager_core.core import KSMCache + from keeper_secrets_manager_core.exceptions import KeeperError, KeeperAccessDenied + from keeper_secrets_manager_core.storage import FileKeyValueStorage # , InMemoryKeyValueStorage + + # from keeper_secrets_manager_core.utils import get_totp_code +except (ImportError, ModuleNotFoundError): + keeper = None + +from django import forms +from django.conf import settings + +# from django.core.exceptions import ValidationError + +from nautobot.apps.secrets import exceptions, SecretsProvider +from nautobot.utilities.forms import BootstrapMixin + +from .choices import KeeperTypeChoices + + +__all__ = ("KeeperSecretsProvider",) + + +try: + plugins_config = settings.PLUGINS_CONFIG["nautobot_secrets_providers"] + KEEPER_TOKEN = plugins_config["keeper"]["token"] +except KeyError: + KEEPER_TOKEN = None + + +class KeeperSecretsProvider(SecretsProvider): + """A secrets provider for Keeper Secrets Manager.""" + + slug = "keeper-secret-manager" + name = "Keeper Secret Manager" + + class ParametersForm(BootstrapMixin, forms.Form): + """Parameters for Keeper Secrets Manager.""" + + name = forms.CharField( + label="Secret Name", + help_text="The secret's name", + max_length=30, + min_length=5, + ) + uid = forms.CharField( + label="Secret UID", + help_text="The secret's uid", + max_length=25, + min_length=20, + ) + token = forms.CharField( + label="Token", + widget=forms.PasswordInput, + help_text="The One Time Token", + max_length=40, + min_length=20, + initial=KEEPER_TOKEN, + ) + """ + https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library + { + "hostname": "keepersecurity.com", + "clientId": "ab2x3z/Acz0QFTiilm8UxIlqNLlNa25KMj=TpOqznwa4Si-h9tY7n3zvFwlXXDoVWkIs3xrMjcLGwgu3ilmq7Q==", + "privateKey": "MLSHAgABCDEFGyqGSM49AEGCCqGSM49AwEHBG0wawIWALTARgmcnWx/DH+r7cKh4kokasdasdaDbvHmLABstNbqDwaCWhRANCAARjunta9SJdZE/LVXfVb22lpIfK4YMkJEDaFMOAyoBt0BrQ8aEhvrHN5/Z1BgZ/WpDm9dMR7E5ASIQuYUiAw0t9", + "serverPublicKeyId": "10", + "appKey": "RzhSIyKxbpjNu045TUrKaNREYIns+Hk9Kn8YtT+CtK0=", + "appOwnerPublicKey": "Sq1W1OAnTwi8V/Vs/lhsin2sfSoaRfOwwDDBqoP+EO9bsBMWCzQdl9ClauDiKLXGmlmyx2xmSAdH+hlxvBRs6kU=" + } + """ + config = forms.JSONField( + label="Config", + help_text="The JSON configuration", + max_length=500, + min_length=70, + ) + # config = forms.CharField( + # required=True, + # help_text="The base64 configuration", + # max_length=300, + # min_length=30, + # ) + type = forms.ChoiceField( + label="Type", + required=True, + choices=KeeperTypeChoices.CHOICES, + help_text="The type of information to retrieve from the secret/record", + ) + + """ + Overloaded clean method to check that at least one of the secret's name or uid is provided + """ + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data.get("name") and not cleaned_data.get("uid"): + raise forms.ValidationError("At least the secret's name or uid must be provided") + if cleaned_data.get("name") and cleaned_data.get("uid"): + raise forms.ValidationError("Only one of the secret's name or uid must be provided") + if not cleaned_data.get("token") and not cleaned_data.get("config"): + raise forms.ValidationError("At least the token or config must be provided") + return cleaned_data + + @classmethod + def get_value_for_secret(cls, secret, obj=None, **kwargs): + """Return the secret value.""" + # Extract the parameters from the Secret. + + parameters = secret.rendered_parameters(obj=obj) + + if keeper is None: + raise exceptions.SecretProviderError( + secret, cls, "The Python dependency keeper_secrets_manager_core is not installed" + ) + + try: + if "name" in parameters: + secret_name = parameters["name"] + if "uid" in parameters: + secret_uid = parameters["uid"] + token = parameters.get("token", KEEPER_TOKEN) + if "config" in parameters: + config = parameters["config"] + type = parameters.get("type") + except KeyError as err: + msg = f"The secret parameter could not be retrieved for field {err}" + raise exceptions.SecretParametersError(secret, cls, msg) from err + + if not KEEPER_TOKEN and not token and not config: + raise exceptions.SecretProviderError( + secret, cls, "Nor the Token or config is configured, at least 1 is required!" + ) + + if not secret_name and not secret_uid: + raise exceptions.SecretProviderError(secret, cls, "At least the secret's name or uid must be provided!") + + # Ensure required parameters are set + if any([not all([secret_name, secret_uid, token, config, type])]): + raise exceptions.SecretProviderError( + secret, + "Keeper Secret Manager is not configured!", + ) + + try: + # Create a Secrets Manager client. + secrets_manager = SecretsManager( + token=token, + # config=InMemoryKeyValueStorage(config), + config=FileKeyValueStorage("config.json"), + log_level="DEBUG" if os.environ.get("DEBUG", None) else "ERROR", + custom_post_function=KSMCache.caching_post_function, + ) + except (KeeperError, KeeperAccessDenied) as err: + msg = f"Unable to connect to Keeper Secret Manager {err}" + raise exceptions.SecretProviderError(secret, msg) from err + except Exception as err: + msg = f"Unable to connect to Keeper Secret Manager {err}" + raise exceptions.SecretProviderError(secret, msg) from err + + if secret_uid: + try: + secret = secrets_manager.get_secrets(uids=secret_uid)[0] + # # https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation + # secret = secrets_manager.get_notation(f'{secret_uid}/field/{type}')[0] + except Exception as err: + msg = f"The secret could not be retrieved using uid {err}" + raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err + elif secret_name: + try: + secret = secrets_manager.get_secret_by_title(secret_name) + except Exception as err: + msg = f"The secret could not be retrieved using name {err}" + raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err + else: + msg = f"At least the secret's name or uid must be provided" + raise exceptions.SecretValueNotFoundError(secret, cls, msg) + + try: + my_secret_info = secret.field(type, single=True) + # api_key = secret.custom_field('API Key', single=True) + # url = secret.get_standard_field_value('oneTimeCode', True) + # totp = get_totp_code(url) + # https://github.com/Keeper-Security/secrets-manager/blob/master/sdk/python/core/keeper_secrets_manager_core/utils.py#L124C24-L124C24: + except Exception as err: + msg = f"The secret field could not be retrieved {err}" + raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err + + return my_secret_info diff --git a/poetry.lock b/poetry.lock index 99fe40d..3aceec6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1127,6 +1127,24 @@ files = [ [package.dependencies] Django = ">=2.2" +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "emoji" version = "2.8.0" @@ -1566,6 +1584,22 @@ files = [ importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} referencing = ">=0.28.0" +[[package]] +name = "keeper-secrets-manager-core" +version = "16.6.2" +description = "Keeper Secrets Manager for Python 3" +optional = true +python-versions = ">=3.6" +files = [ + {file = "keeper-secrets-manager-core-16.6.2.tar.gz", hash = "sha256:bc22c3141863ebaa0b1c98a806487a329675e03ef36bb908ca23dacce92152cb"}, +] + +[package.dependencies] +cryptography = ">=39.0.1" +ecdsa = "*" +importlib_metadata = "*" +requests = "*" + [[package]] name = "kombu" version = "5.3.2" @@ -3677,9 +3711,10 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] [extras] -all = ["boto3", "hvac", "python-tss-sdk"] +all = ["boto3", "hvac", "keeper-secrets-manager-core", "python-tss-sdk"] aws = ["boto3"] hashicorp = ["hvac"] +keeper = ["keeper-secrets-manager-core"] nautobot = ["nautobot"] thycotic = ["python-tss-sdk"] diff --git a/pyproject.toml b/pyproject.toml index 0797950..384dd56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ hvac = { version = ">=0.11.0, <1.1.0", optional = true } nautobot = "^2.0.0" python = ">=3.8,<3.12" python-tss-sdk = {version = "~1.2.0", optional = true} +keeper-secrets-manager-core = {version = "~16", optional = true} [tool.poetry.group.dev.dependencies] Markdown = "*" @@ -69,11 +70,13 @@ all = [ "boto3", "hvac", "python-tss-sdk", + "keeper-secrets-manager-core", ] aws = ["boto3"] hashicorp = ["hvac"] nautobot = ["nautobot"] thycotic = ["python-tss-sdk"] +keeper = ["keeper-secrets-manager-core"] [tool.black] line-length = 120