diff --git a/README.md b/README.md index 388c45c..e2d92a9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ Whispr (Pronounced as whisper) is a CLI tool to safely inject secrets from your Whispr uses keys (with empty values) specified in a `.env` file and fetches respective secrets from a vault, and sets them as environment variables before launching an application. +Install whispr easily with pip! + +```bash +pip install whispr +``` + Key Features of Whispr: * **Safe Secret Injection**: Fetch and inject secrets from your desired vault using HTTPS, SSL encryption, strict CERT validation. @@ -26,11 +32,11 @@ Supported Vault Technologies: # Why use Whispr ? The MITRE ATT&CK Framework Tactic 8 (Credential Access) suggests that adversaries can exploit plain-text secrets and sensitive information stored in files like `.env`. It is essential to avoid storing -sensitive information in unencrypted files. To help developers, Whispr can safely fetch and inject secrets from a vault into the current shell environment. This enables developers to securely manage +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. -# Installation and Setup +# Getting Started ## Installing Whispr @@ -84,9 +90,37 @@ POSTGRES_PASSWORD= **Note**: Use respective authentication methods for other vaults. -## Launch any Application using Whispr +## 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: + +```bash +pip install whispr +``` + +Then from Python code you can import important functions like this: -Now, you can run any app using: `whispr run ''` (mind the single quotes around command) to inject your secrets before starting the subprocess. +```py +from whispr.utils.vault import fetch_secrets +from whispr.utils.process import execute_command + +config = { + "vault": "aws", + "secret_name": "", + "region": "us-west-2" +} + +secrets = fetch_secrets(config) + +# Now, inject secrets into your command's environment +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 @@ -97,13 +131,11 @@ whispr run '/bin/sh ./script.sh' # Inject secrets and run a custom bash script. whispr run 'semgrep scan --pro' # Inject Semgrep App Token and scan current directory with Semgrep SAST tool. ``` -## Programmatic Access - -Whispr can also be used programmatically from Python code. See this guide for more information. - -https://github.com/narenaryan/whispr/blob/docs/main/usage-guides/programmatic-access.md - # TODO -* Support HashiCorp Vault -* Support 1Password Vault +Support: + +* HashiCorp Vault +* 1Password Vault +* K8s secret patching +* Container patching (docker) diff --git a/clean_build.sh b/clean_build.sh index fc3d8fc..a367892 100755 --- a/clean_build.sh +++ b/clean_build.sh @@ -1,4 +1,4 @@ rm -rf dist pip uninstall -q --exists-action=w whispr hatch build -pip install -q dist/whispr-0.3.0-py3-none-any.whl +pip install -q dist/whispr-$1-py3-none-any.whl diff --git a/src/whispr/__about__.py b/src/whispr/__about__.py index 5140fa1..819a300 100644 --- a/src/whispr/__about__.py +++ b/src/whispr/__about__.py @@ -1 +1 @@ -version = "0.4.0" +version = "0.5.0" diff --git a/src/whispr/aws.py b/src/whispr/aws.py index 47ef34c..bddc930 100644 --- a/src/whispr/aws.py +++ b/src/whispr/aws.py @@ -35,6 +35,7 @@ def fetch_secrets(self, secret_name: str) -> str: self.logger.error( "The secret is not found on AWS. Did you set the right AWS_DEFAULT_REGION ?", secret_name=secret_name, + region=self.client.meta.region_name, ) return "" elif error.response["Error"]["Code"] == "UnrecognizedClientException": diff --git a/src/whispr/factory.py b/src/whispr/factory.py index 85f016e..3aff923 100644 --- a/src/whispr/factory.py +++ b/src/whispr/factory.py @@ -1,5 +1,7 @@ """Vault factory""" +import os + import boto3 import botocore.exceptions import structlog @@ -17,6 +19,32 @@ class VaultFactory: """A factory class to create client objects""" + @staticmethod + def _get_aws_region(kwargs: dict) -> str: + """ + Retrieves the AWS region from the provided kwargs or environment variable. + + :param kwargs: Any additional parameters required for specific vault clients. + + Order of preference: + 1. 'region' key in kwargs + 2. AWS_DEFAULT_REGION environment variable + + Raises: + ValueError: If neither source provides a region.""" + + region = kwargs.get("region") + + if not region: + region = os.environ.get("AWS_DEFAULT_REGION") + + if not region: + raise ValueError( + "AWS Region not found. Please fill the `region` (Ex: us-west-2) in Whispr config or set AWS_DEFAULT_REGION environment variable." + ) + + return region + @staticmethod def get_vault(**kwargs) -> SimpleVault: """ @@ -26,6 +54,9 @@ def get_vault(**kwargs) -> SimpleVault: :param logger: Structlog logger instance. :param kwargs: Any additional parameters required for specific vault clients. :return: An instance of a concrete Secret manager class. + + Raises: + ValueError: If sufficient information is not avaiable to initialize vault instance. """ vault_type = kwargs.get("vault") sso_profile = kwargs.get("sso_profile") @@ -33,17 +64,19 @@ def get_vault(**kwargs) -> SimpleVault: logger.info("Initializing vault", vault_type=vault_type) if vault_type == VaultType.AWS.value: - client = boto3.client("secretsmanager") + region = VaultFactory._get_aws_region(kwargs) + client = boto3.client("secretsmanager", region_name=region) # When SSO profile is supplied use the session client if sso_profile: try: session = boto3.Session(profile_name=sso_profile) - client = session.client("secretsmanager") + client = session.client("secretsmanager", region_name=region) except botocore.exceptions.ProfileNotFound: raise ValueError( f"The config profile {sso_profile} could not be found for vault: `{vault_type}`. Please check your AWS SSO config file and retry." ) + return AWSVault(logger, client) elif vault_type == VaultType.AZURE.value: diff --git a/src/whispr/utils/process.py b/src/whispr/utils/process.py index 9bbb8d9..163bafb 100644 --- a/src/whispr/utils/process.py +++ b/src/whispr/utils/process.py @@ -5,7 +5,9 @@ from whispr.logging import logger -def execute_command(command: tuple, no_env: bool, secrets: dict): +def execute_command( + command: tuple, no_env: bool, secrets: dict +) -> subprocess.CompletedProcess[bytes]: """Executes a Unix/Windows command. Arg: `no_env` decides whether secrets are passed vai environment or K:V pairs in command arguments. """ @@ -23,6 +25,7 @@ def execute_command(command: tuple, no_env: bool, secrets: dict): os.environ.update(secrets) sp = subprocess.run(usr_command, env=os.environ, shell=False, check=True) + return sp except subprocess.CalledProcessError as e: logger.error( f"Encountered a problem while running command: '{command[0]}'. Aborting." diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_aws.py b/tests/test_aws.py index 3c09ef8..cc5bf88 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -38,11 +38,14 @@ def test_fetch_secrets_resource_not_found(self): {"Error": {"Code": "ResourceNotFoundException"}}, "get_secret_value" ) + self.mock_client.meta.region_name = "us-east-1" + result = self.vault.fetch_secrets("non_existent_secret") self.assertEqual(result, "") self.mock_logger.error.assert_called_with( "The secret is not found on AWS. Did you set the right AWS_DEFAULT_REGION ?", secret_name="non_existent_secret", + region="us-east-1", ) @patch("whispr.aws.AWSVault.fetch_secrets") diff --git a/tests/test_factory.py b/tests/test_factory.py index bf27c99..487ea8a 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,3 +1,4 @@ +from functools import wraps import os import unittest from unittest.mock import MagicMock, patch @@ -11,13 +12,38 @@ from whispr.factory import VaultFactory +def patch_env_var(var_name, var_value): + """ + Test util to patch a given environment variable safely. + + :param var_name: Environment variable to patch (Ex: AWS_DEFAULT_REGION) + :param var_value: Environment variable value for testing (Ex: us-east-1) + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + original_value = os.environ.get(var_name) + os.environ[var_name] = var_value + try: + return func(*args, **kwargs) + finally: + if original_value is not None: + os.environ[var_name] = original_value + else: + del os.environ[var_name] + + return wrapper + + return decorator + + class FactoryTestCase(unittest.TestCase): """Unit tests for Factory method to create vaults""" def setUp(self): """Set up mocks for logger, GCP client, and project_id before each test.""" self.mock_logger = MagicMock() - os.environ["AWS_DEFAULT_REGION"] = "us-west-2" def test_get_aws_vault_simple_client(self): """Test AWSVault client without SSO""" @@ -26,6 +52,7 @@ def test_get_aws_vault_simple_client(self): "env": ".env", "secret_name": "dummy_secret", "logger": self.mock_logger, + "region": "us-east-2", } vault_instance = VaultFactory.get_vault(**config) self.assertIsInstance(vault_instance, AWSVault) @@ -39,6 +66,7 @@ def test_get_aws_vault_sso_client(self, mock_session): "secret_name": "dummy_secret", "sso_profile": "dev", "logger": self.mock_logger, + "region": "us-east-2", } vault_instance = VaultFactory.get_vault(**config) self.assertIsInstance(vault_instance, AWSVault) @@ -53,12 +81,71 @@ def test_get_aws_vault_sso_client_profile_not_found(self, mock_session): "secret_name": "dummy_secret", "sso_profile": "dev", "logger": self.mock_logger, + "region": "us-east-1", } mock_session.side_effect = botocore.exceptions.ProfileNotFound(profile="dev") with self.assertRaises(ValueError): VaultFactory.get_vault(**config) + @patch("boto3.client") + def test_get_aws_vault_region_passed_explicitly(self, mock_boto_client): + """Test AWSVault client with region passed explicitly in config""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "region": "us-west-2", # Explicit region + "logger": self.mock_logger, + } + print(VaultFactory.get_vault(**config).client) + mock_boto_client.assert_called_with("secretsmanager", region_name="us-west-2") + + @patch("boto3.client") + @patch_env_var("AWS_DEFAULT_REGION", "us-east-1") + def test_get_aws_vault_region_from_env_variable(self, mock_boto_client): + """Test AWSVault client with region from AWS_DEFAULT_REGION environment variable""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "logger": self.mock_logger, + } + VaultFactory.get_vault(**config) + mock_boto_client.assert_called() + mock_boto_client.assert_called_with("secretsmanager", region_name="us-east-1") + + @patch("boto3.client") + def test_get_aws_vault_region_not_passed_nor_in_env_raises_error( + self, mock_boto_client + ): + """Test AWSVault raises error when region is neither passed nor in AWS_DEFAULT_REGION environment variable""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "logger": self.mock_logger, + } + with self.assertRaises(ValueError): + VaultFactory.get_vault(**config) + mock_boto_client.assert_not_called() # Client should not be called if error is raised + + @patch("boto3.client") + @patch_env_var("AWS_DEFAULT_REGION", "us-east-1") # Set env variable + def test_get_aws_vault_region_passed_takes_precedence_over_env_variable( + self, mock_boto_client + ): + """Test AWSVault client with region passed explicitly takes precedence over AWS_DEFAULT_REGION environment variable""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "region": "us-west-2", # Explicit region + "logger": self.mock_logger, + } + VaultFactory.get_vault(**config) + mock_boto_client.assert_called_with("secretsmanager", region_name="us-west-2") + def test_get_azure_vault_client(self): """Test AzureVault client""" config = { diff --git a/tests/test_process_utils.py b/tests/test_process_utils.py index ac01dad..a982da8 100644 --- a/tests/test_process_utils.py +++ b/tests/test_process_utils.py @@ -34,7 +34,12 @@ def test_execute_command_with_env( self, mock_env_update, mock_subprocess_run, mock_logger ): """Test execute_command with `no_env=False`, passing secrets via environment variables.""" - execute_command(self.command, no_env=False, secrets=self.secrets) + completed_process = subprocess.CompletedProcess( + args=["echo", "Hello"], returncode=0, stdout=b"Hello\n", stderr=b"" + ) + mock_subprocess_run.return_value = completed_process + + result = execute_command(self.command, no_env=False, secrets=self.secrets) mock_env_update.assert_called_once_with(self.secrets) expected_command = ["echo", "Hello"] @@ -42,6 +47,11 @@ def test_execute_command_with_env( expected_command, env=os.environ, shell=False, check=True ) + self.assertIsInstance(result, subprocess.CompletedProcess) + self.assertEqual( + type(result.stdout), bytes + ) # Additional sanity check on stdout type + @patch("whispr.utils.process.logger", new_callable=lambda: MagicMock()) @patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "test")) def test_execute_command_called_process_error( diff --git a/usage-guides/programmatic-access.md b/usage-guides/programmatic-access.md deleted file mode 100644 index 5a11714..0000000 --- a/usage-guides/programmatic-access.md +++ /dev/null @@ -1,27 +0,0 @@ -## Programmatic access of Whispr - -In addition to installing Whispr as a tool, one can make use of core utility functions like this: - -```bash -pip install whispr -``` - -Then from Python code you can import important functions like this: - -```py -from whispr.utils.vault import fetch_secrets -from whispr.utils.process import execute_command - -config = { - "vault": "aws", - "secret_name": "" -} - -secrets = fetch_secrets(config) - -# Now, inject secrets into your command's environment by calling this function -command = "ls -l" -execute_command(command.split(), no_env=False, secrets=secrets) -``` - -That's it. This is a programmatic equivalent to the tool usage.