From 2fbced520686262ea226592834354e7d8b999114 Mon Sep 17 00:00:00 2001 From: N3N Date: Sun, 5 Jan 2025 22:06:52 -0800 Subject: [PATCH 01/12] feat: add initial version of get secret (AWS) --- src/whispr/__about__.py | 2 +- src/whispr/cli.py | 47 +++++++++++++++++++++++++++++++++++++++++ src/whispr/logging.py | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/whispr/__about__.py b/src/whispr/__about__.py index 819a300..aaf05b3 100644 --- a/src/whispr/__about__.py +++ b/src/whispr/__about__.py @@ -1 +1 @@ -version = "0.5.0" +version = "0.6.0" diff --git a/src/whispr/cli.py b/src/whispr/cli.py index f98cf0a..a489149 100644 --- a/src/whispr/cli.py +++ b/src/whispr/cli.py @@ -1,6 +1,7 @@ """whispr CLI entrypoint""" import os +import json import click @@ -78,5 +79,51 @@ def run(command): cli.add_command(init) cli.add_command(run) +# Secret group + +@click.group() +def secret(): + """ Whispr secret sub-group manages a secret lifecycle. + + Availble subcommands: [get, rotate] + + Example: whispr secret get --vault=aw --secret-name=my-secret --region=us-west-2 + """ + pass + +cli.add_command(secret) + +@click.command() +@click.option("--secret-name", nargs=1, type=click.STRING) +@click.option("--vault", nargs=1, type=click.STRING) +@click.option("--region", nargs=1, type=click.STRING) +def get(region, vault, secret_name): + """Fetches a vault secret and prints to standard output in JSON format""" + if not vault: + logger.error( + f"No vault type is provided to secret get command. Use vault=aws/azure/gcp as vaules." + ) + return + + if not secret_name: + logger.error( + f"No secret name is provided to secret get command. Use secret_name= option." + ) + return + + config = { + "secret_name": secret_name, + "vault": vault, + "region": region + } + # Fetch secret based on the vault type + vault_secrets = fetch_secrets(config) + if not vault_secrets: + return + + print(json.dumps(vault_secrets, indent=4)) + +secret.add_command(get) + if __name__ == "__main__": cli() diff --git a/src/whispr/logging.py b/src/whispr/logging.py index 59d9ac7..3680738 100644 --- a/src/whispr/logging.py +++ b/src/whispr/logging.py @@ -27,7 +27,7 @@ def setup_structlog() -> structlog.BoundLogger: ) # Set up basic configuration for the standard library logging - logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.INFO) + logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.ERROR) # Return the structlog logger instance return structlog.get_logger() From 92c367cfbf7079ec4ee1a4b5986d429357a55bb8 Mon Sep 17 00:00:00 2001 From: N3N Date: Mon, 13 Jan 2025 18:11:43 -0800 Subject: [PATCH 02/12] refactor: take build version dynamically for clean build test --- clean_build.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clean_build.sh b/clean_build.sh index a367892..f00e84d 100755 --- a/clean_build.sh +++ b/clean_build.sh @@ -1,4 +1,6 @@ rm -rf dist pip uninstall -q --exists-action=w whispr hatch build -pip install -q dist/whispr-$1-py3-none-any.whl +VER=$(ls ./dist/*.whl | sed 's/.*-\([0-9.]*\)-.*/\1/') +echo $VAR +pip install -q dist/whispr-${VER}-py3-none-any.whl From a8708146c8b79e67cf06842df40e49f70b778cb2 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:25:38 -0800 Subject: [PATCH 03/12] feat: add aws and azure get secret commands --- src/whispr/cli.py | 91 ++++++++++++++++++++++++++------------- src/whispr/utils/vault.py | 47 ++++++++++++++++++++ tests/test_vault_utils.py | 74 +++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 30 deletions(-) diff --git a/src/whispr/cli.py b/src/whispr/cli.py index a489149..ced8ef3 100644 --- a/src/whispr/cli.py +++ b/src/whispr/cli.py @@ -12,17 +12,25 @@ ) from whispr.utils.process import execute_command -from whispr.utils.vault import fetch_secrets, get_filled_secrets, prepare_vault_config +from whispr.utils.vault import ( + fetch_secrets, + get_filled_secrets, + prepare_vault_config, + get_raw_secret, +) + +from whispr.utils.crypto import generate_rand_secret CONFIG_FILE = "whispr.yaml" +MIN_GENERATION_LENGTH = 16 @click.group() def cli(): """Whispr is a CLI tool to safely inject vault secrets into an application. - Run `whispr init vault` to create a configuration. + Run `whispr init ` to create a configuration. - Availble values for vault: ["aws", "azure", "gcp"] + Availble values for : ["aws", "azure", "gcp"] """ pass @@ -79,51 +87,74 @@ def run(command): cli.add_command(init) cli.add_command(run) -# Secret group +# Secret group @click.group() def secret(): - """ Whispr secret sub-group manages a secret lifecycle. + """`whispr secret` group manages a secret lifecycle. - Availble subcommands: [get, rotate] + Availble subcommands: [get, gen-random] - Example: whispr secret get --vault=aw --secret-name=my-secret --region=us-west-2 + Examples:\n + 1. whispr secret get --vault=aws --secret-name=my-secret --region=us-west-2 \n + 2. whispr secret gen-random --length=10 + 3. whispr secret gen-random --exclude="*;>/\'" """ pass + +# Add secret command group cli.add_command(secret) + @click.command() -@click.option("--secret-name", nargs=1, type=click.STRING) -@click.option("--vault", nargs=1, type=click.STRING) -@click.option("--region", nargs=1, type=click.STRING) -def get(region, vault, secret_name): +@click.option("-s", "--secret-name", nargs=1, type=click.STRING) +@click.option("-v", "--vault", nargs=1, type=click.STRING) +@click.option("-r", "--region", nargs=1, type=click.STRING) # AWS +@click.option("-vu", "--vault-url", nargs=1, type=click.STRING) # Azure +def get(secret_name, vault, region, vault_url): """Fetches a vault secret and prints to standard output in JSON format""" - if not vault: - logger.error( - f"No vault type is provided to secret get command. Use vault=aws/azure/gcp as vaules." - ) - return - - if not secret_name: - logger.error( - f"No secret name is provided to secret get command. Use secret_name= option." - ) - return - - config = { - "secret_name": secret_name, - "vault": vault, - "region": region - } - # Fetch secret based on the vault type - vault_secrets = fetch_secrets(config) + vault_secrets = get_raw_secret( + secret_name, + vault, + region=region, + vault_url=vault_url, + ) if not vault_secrets: return print(json.dumps(vault_secrets, indent=4)) + +@click.command() +@click.option( + "--length", + nargs=1, + type=click.INT, + help=f"Length of generated secret. Default is {MIN_GENERATION_LENGTH}", +) +@click.option( + "--exclude", + nargs=1, + type=click.STRING, + help="Characters to exclude from secret. Use Escape (\\) to escape special characters", +) +def gen_random(length, exclude): + """Generates a cryptographically secure random secret of a given length, excluding specified characters""" + + exclude_chars = exclude + if not exclude_chars: + exclude_chars = "" + + if not length: + length = MIN_GENERATION_LENGTH + + secret = generate_rand_secret(length=length, exclude_chars=exclude_chars) + print(secret) + + secret.add_command(get) +secret.add_command(gen_random) if __name__ == "__main__": cli() diff --git a/src/whispr/utils/vault.py b/src/whispr/utils/vault.py index 6306cbf..943d3f1 100644 --- a/src/whispr/utils/vault.py +++ b/src/whispr/utils/vault.py @@ -70,3 +70,50 @@ def prepare_vault_config(vault_type: str) -> dict: config["vault"] = VaultType.AZURE.value return config + + +def get_raw_secret(secret_name: str, vault: str, **kwargs) -> dict: + """Get raw secret from vault""" + + if not vault: + logger.error( + "No vault type is provided to get-secret sub command. Use --vault=aws/azure/gcp as value." + ) + return {} + + if not secret_name: + logger.error( + "No secret name is provided to get-secret sub command. Use --secret_name= option." + ) + return {} + + region = kwargs.get("region") + vault_url = kwargs.get("vault_url") + + config = {} + + if vault == VaultType.AWS.value: + if not region: + logger.error( + f"No region option provided to get-secret sub command for vault: {vault}. Use --region= option." + ) + return {} + + config = {"secret_name": secret_name, "vault": vault, "region": region} + elif vault == VaultType.AZURE.value: + if not vault_url: + logger.error( + "No Azure vault URL option is provided to get-secret sub command. Use --vault-url= option." + ) + return {} + + config = { + "secret_name": secret_name, + "vault": vault, + "vault_url": vault_url, + } + + # Fetch secret based on the vault type + vault_secrets = fetch_secrets(config) + + return vault_secrets diff --git a/tests/test_vault_utils.py b/tests/test_vault_utils.py index fe066f7..3df41cc 100644 --- a/tests/test_vault_utils.py +++ b/tests/test_vault_utils.py @@ -1,9 +1,11 @@ import unittest +import string from unittest.mock import patch, MagicMock import json from dotenv import dotenv_values from whispr.utils.vault import fetch_secrets, get_filled_secrets, prepare_vault_config +from whispr.utils.crypto import generate_rand_secret from whispr.enums import VaultType @@ -116,3 +118,75 @@ def test_prepare_vault_config_azure(self): "vault_url": "", } self.assertEqual(config, expected_config) + + +class CryptoUtilitiesTestCase(unittest.TestCase): + """Unit tests for the crypto utilities: generate_rand_secret""" + + def test_basic_generation(self): + """Test that a secret of the correct length is generated when no characters are excluded.""" + length = 12 + secret = generate_rand_secret(length, exclude_chars="") + self.assertEqual(len(secret), length) + # Check that all characters are from the default set + all_chars = string.ascii_letters + string.digits + string.punctuation + for ch in secret: + self.assertIn(ch, all_chars) + + def test_exclusion_of_characters(self): + """Test that specified characters are excluded from the generated secret.""" + length = 10 + exclude_chars = "ABCabc123" + secret = generate_rand_secret(length, exclude_chars=exclude_chars) + # Ensure excluded characters are not present in the secret + for ch in exclude_chars: + self.assertNotIn(ch, secret) + + def test_insufficient_characters(self): + """Test that ValueError is raised if too many characters are excluded, making generation impossible.""" + length = 5 + # Exclude almost everything except 4 characters + # For instance, exclude all uppercase letters, digits, punctuation + # plus 22 of the 26 lowercase letters, leaving only 4 possible chars. + # Then request length=5 => should raise ValueError. + exclude_chars = ( + string.ascii_uppercase + + string.digits + + string.punctuation + + "abcdefghijklmnopqrstuvwxyz"[4:] + ) + with self.assertRaises(ValueError): + generate_rand_secret(length, exclude_chars=exclude_chars) + + @patch("secrets.choice", return_value="X") + def test_mocked_secrets_choice(self, mock_choice): + """ + Test generate_rand_secret with secrets.choice mocked. + This ensures we verify the function calls and final output deterministically. + """ + length = 5 + secret = generate_rand_secret(length, exclude_chars="") + # Since mock returns "X" every time, the result should be "XXXXX" + self.assertEqual(secret, "XXXXX") + + # Verify that secrets.choice was called exactly 'length' times + self.assertEqual(mock_choice.call_count, length) + + def test_zero_length(self): + """ + Test generating a secret of zero length (uncommon, but functionally should return an empty string). + """ + secret = generate_rand_secret(0, exclude_chars="") + self.assertEqual(secret, "", "Zero-length secret should be an empty string.") + + def test_all_punctuation_exclusion(self): + """ + Test excluding all punctuation does not break the generation. + """ + length = 8 + exclude_chars = string.punctuation + secret = generate_rand_secret(length, exclude_chars=exclude_chars) + self.assertEqual(len(secret), length) + # Ensure no punctuation is in the result + for ch in secret: + self.assertNotIn(ch, string.punctuation) From d19ec3d45dfa07134a1afe2a34eb6af743b99a6d Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:26:01 -0800 Subject: [PATCH 04/12] docs: add example whispr config --- whispr.yaml.example | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 whispr.yaml.example diff --git a/whispr.yaml.example b/whispr.yaml.example new file mode 100644 index 0000000..a3d4826 --- /dev/null +++ b/whispr.yaml.example @@ -0,0 +1,17 @@ +## AWS +# env_file: .env +# secret_name: my-creds +# vault: aws +# region: us-west-2 + +## Azure +# env_file: .env +# secret_name: my-creds +# vault: azure +# vault_url: https://my-creds.vault.azure.net/ + +# GCP +# env_file: .env +# secret_name: nyell-db-creds +# vault: gcp +# project_id: project-12345 From 9d5046c0bfe43758f8f830a2e3e0d88fd7f1ac83 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:53:52 -0800 Subject: [PATCH 05/12] docs: add better help strings for commands --- src/whispr/cli.py | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/whispr/cli.py b/src/whispr/cli.py index ced8ef3..e042a08 100644 --- a/src/whispr/cli.py +++ b/src/whispr/cli.py @@ -46,8 +46,14 @@ def init(vault): @click.command() @click.argument("command", nargs=-1, type=click.UNPROCESSED) def run(command): - """Runs a command by injecting secrets fetched from vault via environment or list of command arguments. - Examples: [whispr run 'python main.py', whispr run 'bash script.sh'] + """Runs a command by injecting secrets fetched from vault via environment or list of command arguments. Make sure you run `whispr init` command before using this. + Please note the single quote (\') wrapped around the passed commands. + + Examples:\n + 1. whispr run 'python main.py'\n + 2. whispr run 'bash script.sh'\n + 3. whispr run 'node server.js'\n + 4. whispr run 'semgrep ci --pro'\n """ if not os.path.exists(CONFIG_FILE): logger.error("whispr configuration file not found. Run 'whispr init' first.") @@ -96,9 +102,16 @@ def secret(): Availble subcommands: [get, gen-random] Examples:\n + # Get a secret from AWS vault\n 1. whispr secret get --vault=aws --secret-name=my-secret --region=us-west-2 \n - 2. whispr secret gen-random --length=10 - 3. whispr secret gen-random --exclude="*;>/\'" + # Get a secret from Azure vault\n + 2. whispr secret get -v gcp -s my-secret -u my_vault_url\n + # Get a secret from GCP vault\n + 3. whispr secret get -v gcp -s my-secret -p my_gcp_project_id\n + # Generate a random string of length of 10 characters.\n + 4. whispr secret gen-random --length=10\n + # Generate a random string of default length but exclude given characters.\n + 5. whispr secret gen-random --exclude="*;>/\'"\n """ pass @@ -108,17 +121,19 @@ def secret(): @click.command() -@click.option("-s", "--secret-name", nargs=1, type=click.STRING) -@click.option("-v", "--vault", nargs=1, type=click.STRING) -@click.option("-r", "--region", nargs=1, type=click.STRING) # AWS -@click.option("-vu", "--vault-url", nargs=1, type=click.STRING) # Azure -def get(secret_name, vault, region, vault_url): - """Fetches a vault secret and prints to standard output in JSON format""" +@click.option("-s", "--secret-name", nargs=1, type=click.STRING, help="Secret name to fetch from a vault") +@click.option("-v", "--vault", nargs=1, type=click.STRING, help="Vault type. Available values: aws, azure, gcp") +@click.option("-r", "--region", nargs=1, type=click.STRING, help="Region (AWS-only property)") # AWS +@click.option("-u", "--vault-url", nargs=1, type=click.STRING, help="Vault URL (Azure-only property)") # Azure +@click.option("-p", "--project-id", nargs=1, type=click.STRING, help="Project ID (GCP-only property)") # GCP +def get(secret_name, vault, region, vault_url, project_id): + """Fetches a vault secret and prints to standard output in JSON format. Output is parseable by `jq` tool. Used for quick audit of secret K:V pairs""" vault_secrets = get_raw_secret( secret_name, vault, region=region, vault_url=vault_url, + project_id=project_id, ) if not vault_secrets: return @@ -128,19 +143,27 @@ def get(secret_name, vault, region, vault_url): @click.command() @click.option( + "-l", "--length", nargs=1, type=click.INT, help=f"Length of generated secret. Default is {MIN_GENERATION_LENGTH}", ) @click.option( + "-e", "--exclude", nargs=1, type=click.STRING, help="Characters to exclude from secret. Use Escape (\\) to escape special characters", ) def gen_random(length, exclude): - """Generates a cryptographically secure random secret of a given length, excluding specified characters""" + """Generates a crypto-secure random secret of a given length, excluding specified characters. + + Examples:\n + # Generate a random string of length of 10 characters.\n + 1. whispr secret gen-random --length=10\n + # Generate a random string of default length but exclude given characters.\n + 2. whispr secret gen-random --exclude="*;>/\'"\n""" exclude_chars = exclude if not exclude_chars: From a2a6874abd6eb19320ee99fe88db1bb26cd977a7 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:54:41 -0800 Subject: [PATCH 06/12] feat: add gcp get secret support --- src/whispr/cli.py | 36 +++++++++++++++++++++++++++++++----- src/whispr/utils/vault.py | 19 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/whispr/cli.py b/src/whispr/cli.py index e042a08..71a8b4f 100644 --- a/src/whispr/cli.py +++ b/src/whispr/cli.py @@ -121,11 +121,37 @@ def secret(): @click.command() -@click.option("-s", "--secret-name", nargs=1, type=click.STRING, help="Secret name to fetch from a vault") -@click.option("-v", "--vault", nargs=1, type=click.STRING, help="Vault type. Available values: aws, azure, gcp") -@click.option("-r", "--region", nargs=1, type=click.STRING, help="Region (AWS-only property)") # AWS -@click.option("-u", "--vault-url", nargs=1, type=click.STRING, help="Vault URL (Azure-only property)") # Azure -@click.option("-p", "--project-id", nargs=1, type=click.STRING, help="Project ID (GCP-only property)") # GCP +@click.option( + "-s", + "--secret-name", + nargs=1, + type=click.STRING, + help="Secret name to fetch from a vault", +) +@click.option( + "-v", + "--vault", + nargs=1, + type=click.STRING, + help="Vault type. Available values: aws, azure, gcp", +) +@click.option( + "-r", "--region", nargs=1, type=click.STRING, help="Region (AWS-only property)" +) # AWS +@click.option( + "-u", + "--vault-url", + nargs=1, + type=click.STRING, + help="Vault URL (Azure-only property)", +) # Azure +@click.option( + "-p", + "--project-id", + nargs=1, + type=click.STRING, + help="Project ID (GCP-only property)", +) # GCP def get(secret_name, vault, region, vault_url, project_id): """Fetches a vault secret and prints to standard output in JSON format. Output is parseable by `jq` tool. Used for quick audit of secret K:V pairs""" vault_secrets = get_raw_secret( diff --git a/src/whispr/utils/vault.py b/src/whispr/utils/vault.py index 943d3f1..6f2b90c 100644 --- a/src/whispr/utils/vault.py +++ b/src/whispr/utils/vault.py @@ -73,7 +73,7 @@ def prepare_vault_config(vault_type: str) -> dict: def get_raw_secret(secret_name: str, vault: str, **kwargs) -> dict: - """Get raw secret from vault""" + """Get raw secret from the vault""" if not vault: logger.error( @@ -87,15 +87,16 @@ def get_raw_secret(secret_name: str, vault: str, **kwargs) -> dict: ) return {} + # Parse kwargs region = kwargs.get("region") vault_url = kwargs.get("vault_url") - + project_id = kwargs.get("project_id") config = {} if vault == VaultType.AWS.value: if not region: logger.error( - f"No region option provided to get-secret sub command for vault: {vault}. Use --region= option." + "No region option provided to get-secret sub command for AWS Vault. Use --region= option." ) return {} @@ -112,6 +113,18 @@ def get_raw_secret(secret_name: str, vault: str, **kwargs) -> dict: "vault": vault, "vault_url": vault_url, } + elif vault == VaultType.GCP.value: + if not project_id: + logger.error( + "No project ID option is provided to get-secret sub command for GCP Vault. Use --project-id= option." + ) + return {} + + config = { + "secret_name": secret_name, + "vault": vault, + "project_id": project_id, + } # Fetch secret based on the vault type vault_secrets = fetch_secrets(config) From 98e58b5fea412393a285bb78585988dd010aa35c Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:55:17 -0800 Subject: [PATCH 07/12] feat: add gen random command logic --- src/whispr/utils/crypto.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/whispr/utils/crypto.py diff --git a/src/whispr/utils/crypto.py b/src/whispr/utils/crypto.py new file mode 100644 index 0000000..ca4e20d --- /dev/null +++ b/src/whispr/utils/crypto.py @@ -0,0 +1,25 @@ +import string +import secrets + + +def generate_rand_secret(length: int, exclude_chars: str) -> str: + """ + Generates a cryptographically secure random secret, excluding specified characters. + """ + # Define the default character set + all_characters = string.ascii_letters + string.digits + string.punctuation + effective_characters = all_characters + + if exclude_chars: + effective_characters = [ + char for char in all_characters if char not in exclude_chars + ] + + # Check if the exclusion set doesn't leave us with too few characters + if len(effective_characters) < length: + raise ValueError( + f"Excluding '{exclude_chars}' leaves insufficient characters to generate a {length}-character secret." + ) + + # Generate the secret using secrets.choice for cryptographically secure randomness + return "".join(secrets.choice(effective_characters) for _ in range(length)) From 1956b92ca90f00c44568b9bd5a950bee13a1f055 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:57:20 -0800 Subject: [PATCH 08/12] docs: add dummy values to example file --- whispr.yaml.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/whispr.yaml.example b/whispr.yaml.example index a3d4826..0debd55 100644 --- a/whispr.yaml.example +++ b/whispr.yaml.example @@ -1,17 +1,17 @@ ## AWS # env_file: .env -# secret_name: my-creds +# secret_name: my-secret # vault: aws # region: us-west-2 ## Azure # env_file: .env -# secret_name: my-creds +# secret_name: my-secret # vault: azure # vault_url: https://my-creds.vault.azure.net/ # GCP # env_file: .env -# secret_name: nyell-db-creds +# secret_name: my-secret # vault: gcp # project_id: project-12345 From 21e8281c8eb617d4cf4ff75fea416c96e905b3eb Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 19:57:38 -0800 Subject: [PATCH 09/12] docs: add dummy values to example file, amend --- whispr.yaml.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whispr.yaml.example b/whispr.yaml.example index 0debd55..f72a55f 100644 --- a/whispr.yaml.example +++ b/whispr.yaml.example @@ -10,7 +10,7 @@ # vault: azure # vault_url: https://my-creds.vault.azure.net/ -# GCP +## GCP # env_file: .env # secret_name: my-secret # vault: gcp From 029c899fd923517d28c411eb1166fe51f5c38482 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 20:05:20 -0800 Subject: [PATCH 10/12] tests: add test case for get raw secret --- tests/test_vault_utils.py | 137 +++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/tests/test_vault_utils.py b/tests/test_vault_utils.py index 3df41cc..a5c703d 100644 --- a/tests/test_vault_utils.py +++ b/tests/test_vault_utils.py @@ -2,9 +2,9 @@ import string from unittest.mock import patch, MagicMock import json -from dotenv import dotenv_values from whispr.utils.vault import fetch_secrets, get_filled_secrets, prepare_vault_config +from whispr.utils.vault import get_raw_secret from whispr.utils.crypto import generate_rand_secret from whispr.enums import VaultType @@ -190,3 +190,138 @@ def test_all_punctuation_exclusion(self): # Ensure no punctuation is in the result for ch in secret: self.assertNotIn(ch, string.punctuation) + + +class GetRawSecretTestCase(unittest.TestCase): + """Unit tests for get_raw_secret function.""" + + def setUp(self): + """Set up shared test data.""" + self.secret_name = "test_secret" + self.aws_region = "us-east-1" + self.azure_vault_url = "https://my-azure-vault.vault.azure.net/" + self.gcp_project_id = "my-gcp-project" + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_no_vault_provided(self, mock_fetch_secrets, mock_logger): + """Test that an empty dict is returned and an error is logged when no vault is provided.""" + mock_fetch_secrets.return_value = {"some_key": "some_value"} + + result = get_raw_secret(self.secret_name, vault="") + + # Expect an empty dict, an error log, and no call to fetch_secrets + self.assertEqual(result, {}) + mock_logger.error.assert_called_once() + mock_fetch_secrets.assert_not_called() + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_no_secret_name_provided(self, mock_fetch_secrets, mock_logger): + """Test that an empty dict is returned and an error is logged when no secret name is provided.""" + mock_fetch_secrets.return_value = {"some_key": "some_value"} + + result = get_raw_secret(secret_name="", vault=VaultType.AWS.value, region=self.aws_region) + + self.assertEqual(result, {}) + mock_logger.error.assert_called_once() + mock_fetch_secrets.assert_not_called() + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_aws_missing_region(self, mock_fetch_secrets, mock_logger): + """Test that an empty dict is returned and an error is logged for AWS if region is missing.""" + mock_fetch_secrets.return_value = {"aws_key": "aws_value"} + + result = get_raw_secret(self.secret_name, VaultType.AWS.value) + + self.assertEqual(result, {}) + mock_logger.error.assert_called_once() + mock_fetch_secrets.assert_not_called() + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_azure_missing_vault_url(self, mock_fetch_secrets, mock_logger): + """Test that an empty dict is returned and an error is logged for Azure if vault_url is missing.""" + mock_fetch_secrets.return_value = {"azure_key": "azure_value"} + + result = get_raw_secret(self.secret_name, VaultType.AZURE.value) + + self.assertEqual(result, {}) + mock_logger.error.assert_called_once() + mock_fetch_secrets.assert_not_called() + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_gcp_missing_project_id(self, mock_fetch_secrets, mock_logger): + """Test that an empty dict is returned and an error is logged for GCP if project_id is missing.""" + mock_fetch_secrets.return_value = {"gcp_key": "gcp_value"} + + result = get_raw_secret(self.secret_name, VaultType.GCP.value) + + self.assertEqual(result, {}) + mock_logger.error.assert_called_once() + mock_fetch_secrets.assert_not_called() + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_aws_success(self, mock_fetch_secrets, mock_logger): + """Test successful retrieval for AWS with valid region.""" + expected_response = {"aws_key": "aws_value"} + mock_fetch_secrets.return_value = expected_response + + result = get_raw_secret( + secret_name=self.secret_name, + vault=VaultType.AWS.value, + region=self.aws_region + ) + + self.assertEqual(result, expected_response) + mock_logger.error.assert_not_called() + mock_fetch_secrets.assert_called_once_with({ + "secret_name": self.secret_name, + "vault": VaultType.AWS.value, + "region": self.aws_region + }) + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_azure_success(self, mock_fetch_secrets, mock_logger): + """Test successful retrieval for Azure with valid vault_url.""" + expected_response = {"azure_key": "azure_value"} + mock_fetch_secrets.return_value = expected_response + + result = get_raw_secret( + secret_name=self.secret_name, + vault=VaultType.AZURE.value, + vault_url=self.azure_vault_url + ) + + self.assertEqual(result, expected_response) + mock_logger.error.assert_not_called() + mock_fetch_secrets.assert_called_once_with({ + "secret_name": self.secret_name, + "vault": VaultType.AZURE.value, + "vault_url": self.azure_vault_url + }) + + @patch("whispr.utils.vault.logger", new_callable=MagicMock) + @patch("whispr.utils.vault.fetch_secrets") + def test_gcp_success(self, mock_fetch_secrets, mock_logger): + """Test successful retrieval for GCP with valid project_id.""" + expected_response = {"gcp_key": "gcp_value"} + mock_fetch_secrets.return_value = expected_response + + result = get_raw_secret( + secret_name=self.secret_name, + vault=VaultType.GCP.value, + project_id=self.gcp_project_id + ) + + self.assertEqual(result, expected_response) + mock_logger.error.assert_not_called() + mock_fetch_secrets.assert_called_once_with({ + "secret_name": self.secret_name, + "vault": VaultType.GCP.value, + "project_id": self.gcp_project_id + }) From a68b484ef73dd2ac05bfd05c8cb5ec18d39e9cb0 Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 20:20:45 -0800 Subject: [PATCH 11/12] docs: update usage guides --- pyproject.toml | 6 +++--- usage-guides/aws.md | 10 +++++++++- usage-guides/azure.md | 11 +++++++++-- usage-guides/gcp.md | 9 ++++++++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83ca5a7..e43f203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,9 @@ dependencies = [ "hvac==2.3.0", ] [project.urls] -Documentation = "https://github.com/narenaryan/whispr/blob/main/README.md" -Issues = "https://github.com/narenaryan/whispr/issues" -Source = "https://github.com/narenaryan/whispr" +Documentation = "https://github.com/cybrota/whispr/blob/main/README.md" +Issues = "https://github.com/cybrota/whispr/issues" +Source = "https://github.com/cybrota/whispr" [tool.hatch.version] path = "src/whispr/__about__.py" diff --git a/usage-guides/aws.md b/usage-guides/aws.md index 94a262e..107f7b8 100644 --- a/usage-guides/aws.md +++ b/usage-guides/aws.md @@ -19,7 +19,15 @@ Step 2: Initialize a whispr configuration file for AWS. whispr init aws ``` -This creates a file called `whispr.yaml`. Update the details. +This creates a file called `whispr.yaml`. Update the below details. + +```yaml +env_file: .env +secret_name: my-secret +vault: aws +region: us-west-2 # Required for AWS +sso_profile: my_profile # Set in case using a SSO profile for authentication +``` Step 3: Define a `.env` file with secrets stored in AWS (Assuming secrets with below names exist in remote secret as key value pair) ```bash diff --git a/usage-guides/azure.md b/usage-guides/azure.md index 002ca54..d0245d2 100644 --- a/usage-guides/azure.md +++ b/usage-guides/azure.md @@ -18,7 +18,14 @@ Step 2: Initialize a whispr configuration file for Azure. ```bash whispr init azure ``` -This creates a file called `whispr.yaml`. Update the details. +This creates a file called `whispr.yaml`. Update the below details. + +```yaml +env_file: .env +secret_name: my-secret +vault: azure +vault_url: https://my-creds.vault.azure.net/ # Required for Azure +``` Step 3: Define a `.env` file with secrets stored in Azure (Assuming secrets with below names exist in remote secret as key value pair) ```bash @@ -34,4 +41,4 @@ whispr run 'node script.js' DB_USERNAME & DB_PASSWORD are now available in Node.js program environment. ## References: -* https://learn.microsoft.com/en-gb/cli/azure/authenticate-azure-cli-interactively \ No newline at end of file +* https://learn.microsoft.com/en-gb/cli/azure/authenticate-azure-cli-interactively diff --git a/usage-guides/gcp.md b/usage-guides/gcp.md index 9c2cbc6..04140c4 100644 --- a/usage-guides/gcp.md +++ b/usage-guides/gcp.md @@ -11,7 +11,14 @@ Step 2: Initialize a whispr configuration file for GCP. ```bash whispr init gcp ``` -This creates a file called `whispr.yaml`. Update the details. +This creates a file called `whispr.yaml`. Update with the below details. + +```yaml +env_file: .env +secret_name: my-secret +vault: gcp +project_id: project-12345 # Required for GCP +``` Step 3: Define a `.env` file with secrets stored in GCP (Assuming secrets with below names exist in remote secret as key value pair) ```bash From bb6cadb84c01cf55a2f25f1f1e3de0060348d9cf Mon Sep 17 00:00:00 2001 From: N3N Date: Tue, 14 Jan 2025 20:29:42 -0800 Subject: [PATCH 12/12] docs: Update README with new feature changes --- README.md | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e2d92a9..34b11c8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Key Features of Whispr: * **Customizable Configurations**: Configure project-level settings to manage multiple secrets for multiple projects. * **No Custom Scripts Required**: Whispr eliminates the need for custom bash scripts or cloud CLI tools to manage secrets, making it easy to get started. * **Easy Installation**: Cross-platform installation with PyPi. +* **Generate Random Sequences for key rotation**: Whispr can generate crypto-safe random sequences with a given length. Great for secret rotation. Supported Vault Technologies: @@ -35,6 +36,19 @@ The MITRE ATT&CK Framework Tactic 8 (Credential Access) suggests that adversarie sensitive information in unencrypted files. To help developers, Whispr can safely fetch and inject secrets from a vault into the app environment or pass them as standard input just in time. This enables developers to securely manage credentials and mitigate advisory exploitation tactics. +Whispr can also comes with handy utilities like: + +1. Audit a secret from vault + +```sh +whispr secret get --vault=aws --secret-name=my_secret --region=us-east-1 +``` + +2. Generate a crypto-safe random sequences for rotated secrets + +```sh +whispr secret gen-random --length=16 --exclude='*/^' +``` # Getting Started @@ -90,9 +104,21 @@ POSTGRES_PASSWORD= **Note**: Use respective authentication methods for other vaults. +## Launch any Application using Whispr (Requires a configuration file: `whispr.yaml`) +In contrary to programmatic access, if you want to run a script/program do: `whispr run ''` (mind the single quotes around command) to inject your secrets before starting the subprocess. + +Examples: +```bash +whispr run 'python main.py' # Inject secrets and run a Python program +whispr run 'node server.js --threads 4' # Inject secrets and run a Node.js express server +whispr run 'django manage.py runserver' # Inject secrets and start a Django server +whispr run '/bin/sh ./script.sh' # Inject secrets and run a custom bash script. Script should be permitted to execute +whispr run 'semgrep scan --pro' # Inject Semgrep App Token and scan current directory with Semgrep SAST tool. +``` + ## Programmatic access of Whispr (Doesn't require a configuration file) -In addition to installing Whispr as a tool, one can make use of core utility functions like this: +Instead of using Whispr as an execution tool, a Python program can leverage core utility functions like this: ```bash pip install whispr @@ -117,21 +143,9 @@ command = "ls -l" cp = execute_command(command.split(), no_env=False, secrets=secrets) #cp is CompletedProcess object. ``` -That's it. This is a programmatic equivalent to the tool usage. - -## Launch any Application using Whispr (Requires a configuration file: `whispr.yaml`) -In contrary to programmatic access, if you want to run a script/program do: `whispr run ''` (mind the single quotes around command) to inject your secrets before starting the subprocess. - -Examples: -```bash -whispr run 'python main.py' # Inject secrets and run a Python program -whispr run 'node server.js --threads 4' # Inject secrets and run a Node.js express server -whispr run 'django manage.py runserver' # Inject secrets and start a Django server -whispr run '/bin/sh ./script.sh' # Inject secrets and run a custom bash script. Script should be permitted to execute -whispr run 'semgrep scan --pro' # Inject Semgrep App Token and scan current directory with Semgrep SAST tool. -``` +That's it. This is a programmatic equivalent to the tool usage which allows programs to fetch secrets from vault at run time. -# TODO +## TODO Support: @@ -139,3 +153,4 @@ Support: * 1Password Vault * K8s secret patching * Container patching (docker) +* Increase test coverage