From 69e1ffed422b3a34fbe98aa9236ede36b4243476 Mon Sep 17 00:00:00 2001 From: N3N Date: Sat, 7 Dec 2024 21:37:08 -0800 Subject: [PATCH 1/7] feat: add support for AWS SSO profile in config --- src/whispr/cli.py | 18 ++++++++++-------- src/whispr/factory.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/whispr/cli.py b/src/whispr/cli.py index 855ec9a..f98cf0a 100644 --- a/src/whispr/cli.py +++ b/src/whispr/cli.py @@ -11,25 +11,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 CONFIG_FILE = "whispr.yaml" @click.group() def cli(): - """Click group""" + """Whispr is a CLI tool to safely inject vault secrets into an application. + Run `whispr init vault` to create a configuration. + + Availble values for vault: ["aws", "azure", "gcp"] + """ pass @click.command() @click.argument("vault", nargs=1, type=click.STRING) def init(vault): - """Creates a whispr configuration file""" + """Creates a whispr vault configuration file (YAML). This file defines vault properties like secret name and vault type etc.""" config = prepare_vault_config(vault) write_to_yaml_file(config, CONFIG_FILE) @@ -37,7 +37,9 @@ def init(vault): @click.command() @click.argument("command", nargs=-1, type=click.UNPROCESSED) def run(command): - """Fetches secrets and injects them into the environment.""" + """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'] + """ if not os.path.exists(CONFIG_FILE): logger.error("whispr configuration file not found. Run 'whispr init' first.") return diff --git a/src/whispr/factory.py b/src/whispr/factory.py index 42136de..013733f 100644 --- a/src/whispr/factory.py +++ b/src/whispr/factory.py @@ -3,6 +3,7 @@ import os import boto3 +import botocore.exceptions import structlog from azure.identity import DefaultAzureCredential from azure.keyvault.secrets import SecretClient @@ -29,11 +30,22 @@ def get_vault(**kwargs) -> SimpleVault: :return: An instance of a concrete Secret manager class. """ vault_type = kwargs.get("vault") + sso_profile = kwargs.get("sso_profile") logger: structlog.BoundLogger = kwargs.get("logger") logger.info("Initializing vault", vault_type=vault_type) if vault_type == VaultType.AWS.value: client = boto3.client("secretsmanager") + + # When SSO profile is supplied use the session client + if sso_profile: + try: + session = boto3.Session(profile_name=sso_profile) + client = session.client("secretsmanager") + 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: From 9f541640d25f8a8c55d28873c2c2869108493454 Mon Sep 17 00:00:00 2001 From: N3N Date: Sat, 7 Dec 2024 21:40:16 -0800 Subject: [PATCH 2/7] ci: add clean build script to ease local build tests --- clean_build.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100755 clean_build.sh diff --git a/clean_build.sh b/clean_build.sh new file mode 100755 index 0000000..fc3d8fc --- /dev/null +++ b/clean_build.sh @@ -0,0 +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 From dcf7be62fc5ec4902cc44f149d774e282263551a Mon Sep 17 00:00:00 2001 From: N3N Date: Sat, 7 Dec 2024 21:43:20 -0800 Subject: [PATCH 3/7] refactor: change workflow name --- .github/workflows/security.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 610d27b..d00109b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,4 +1,4 @@ -name: Semgrep CI Scan +name: Run security scan on: push: @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - '*' + - "*" jobs: semgrep-scan: @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: "3.10" - name: Install Semgrep run: | From d7c7a867a4d7b42d78eca2601e3a5cc14017e157 Mon Sep 17 00:00:00 2001 From: N3N Date: Sat, 7 Dec 2024 22:39:34 -0800 Subject: [PATCH 4/7] tests: add test cases for factory --- src/whispr/factory.py | 4 +- tests/test_aws.py | 4 +- tests/test_factory.py | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 tests/test_factory.py diff --git a/src/whispr/factory.py b/src/whispr/factory.py index 013733f..85f016e 100644 --- a/src/whispr/factory.py +++ b/src/whispr/factory.py @@ -1,7 +1,5 @@ """Vault factory""" -import os - import boto3 import botocore.exceptions import structlog @@ -22,7 +20,7 @@ class VaultFactory: @staticmethod def get_vault(**kwargs) -> SimpleVault: """ - Factory method to return the appropriate secret manager based on the vault type. + Factory method to return the appropriate secrets manager client based on the vault type. :param vault_type: The type of the vault ('aws', 'azure', 'gcp'). :param logger: Structlog logger instance. diff --git a/tests/test_aws.py b/tests/test_aws.py index 12c3e89..3c09ef8 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -1,12 +1,10 @@ """Tests for AWS module""" import unittest -from unittest.mock import Mock, MagicMock, patch +from unittest.mock import MagicMock, patch import botocore.exceptions -import structlog -from whispr.vault import SimpleVault from whispr.aws import AWSVault diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..fa348a5 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import MagicMock, patch + +import botocore.exceptions + +from whispr.aws import AWSVault +from whispr.azure import AzureVault +from whispr.gcp import GCPVault + +from whispr.factory import VaultFactory + + +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() + + def test_get_aws_vault_simple_client(self): + """Test AWSVault client without SSO""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "logger": self.mock_logger, + } + vault_instance = VaultFactory.get_vault(**config) + self.assertIsInstance(vault_instance, AWSVault) + + @patch("boto3.Session") + def test_get_aws_vault_sso_client(self, mock_session): + """Test AWSVault SSO session client""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "sso_profile": "dev", + "logger": self.mock_logger, + } + vault_instance = VaultFactory.get_vault(**config) + self.assertIsInstance(vault_instance, AWSVault) + mock_session.assert_called_with(profile_name="dev") + + @patch("boto3.Session") + def test_get_aws_vault_sso_client_profile_not_found(self, mock_session): + """Test AWSVault raises exception when sso_profile is defined but not found in AWS config""" + config = { + "vault": "aws", + "env": ".env", + "secret_name": "dummy_secret", + "sso_profile": "dev", + "logger": self.mock_logger, + } + mock_session.side_effect = botocore.exceptions.ProfileNotFound(profile="dev") + with self.assertRaises(ValueError): + VaultFactory.get_vault(**config) + + def test_get_azure_vault_client(self): + """Test AzureVault client""" + config = { + "vault": "azure", + "env": ".env", + "secret_name": "dummy_secret", + "vault_url": "https://example.org", + "logger": self.mock_logger, + } + vault_instance = VaultFactory.get_vault(**config) + self.assertIsInstance(vault_instance, AzureVault) + + def test_get_azure_vault_client_no_url(self): + """Test AzureVault raises exception when vault_url is not defined in config""" + config = { + "vault": "azure", + "env": ".env", + "secret_name": "dummy_secret", + "logger": self.mock_logger, + } + + with self.assertRaises(ValueError): + VaultFactory.get_vault(**config) + + def test_get_gcp_vault_client(self): + """Test GCPVault client""" + config = { + "vault": "gcp", + "env": ".env", + "secret_name": "dummy_secret", + "project_id": "dummy_project", + "logger": self.mock_logger, + } + vault_instance = VaultFactory.get_vault(**config) + self.assertIsInstance(vault_instance, GCPVault) + + def test_get_gcp_vault_client_no_project_id(self): + """Test GCPVault raises exception when project_id is not defined in config""" + config = { + "vault": "gcp", + "env": ".env", + "secret_name": "dummy_secret", + "logger": self.mock_logger, + } + + with self.assertRaises(ValueError): + VaultFactory.get_vault(**config) From a9f084ace130bda7cba5e5caf258cd2cfe2a7423 Mon Sep 17 00:00:00 2001 From: N3N Date: Sat, 7 Dec 2024 22:40:00 -0800 Subject: [PATCH 5/7] release: bump up version to v0.4.0 --- src/whispr/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/whispr/__about__.py b/src/whispr/__about__.py index 779733f..5140fa1 100644 --- a/src/whispr/__about__.py +++ b/src/whispr/__about__.py @@ -1 +1 @@ -version = "0.3.0" +version = "0.4.0" From 7fffdf1bc9ff79779b85d57db80b81cb37e7a534 Mon Sep 17 00:00:00 2001 From: N3N Date: Sun, 8 Dec 2024 08:43:59 -0800 Subject: [PATCH 6/7] fix: add default region for AWS factory tests --- tests/test_factory.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_factory.py b/tests/test_factory.py index fa348a5..1594373 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,3 +1,4 @@ +import os import unittest from unittest.mock import MagicMock, patch @@ -16,6 +17,7 @@ class FactoryTestCase(unittest.TestCase): 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""" @@ -52,6 +54,7 @@ def test_get_aws_vault_sso_client_profile_not_found(self, mock_session): "sso_profile": "dev", "logger": self.mock_logger, } + mock_session.side_effect = botocore.exceptions.ProfileNotFound(profile="dev") with self.assertRaises(ValueError): VaultFactory.get_vault(**config) From a5c90ee635d02c2e13b9dda03c92b1948348a0cf Mon Sep 17 00:00:00 2001 From: N3N Date: Sun, 8 Dec 2024 09:15:55 -0800 Subject: [PATCH 7/7] refactor: ruff format src and tests --- src/whispr/utils/io.py | 2 ++ src/whispr/utils/process.py | 7 +++---- tests/test_factory.py | 4 +++- tests/test_gcp.py | 4 +--- tests/test_io_utils.py | 10 +++++++--- tests/test_process_utils.py | 20 +++++++++++++++----- tests/test_vault_utils.py | 24 +++++++++++++++++------- 7 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/whispr/utils/io.py b/src/whispr/utils/io.py index 90c46c4..4dd9d34 100644 --- a/src/whispr/utils/io.py +++ b/src/whispr/utils/io.py @@ -4,6 +4,7 @@ from whispr.logging import logger + def write_to_yaml_file(config: dict, file_path: str): """Writes a given config object to a file in YAML format""" if not os.path.exists(file_path): @@ -11,6 +12,7 @@ def write_to_yaml_file(config: dict, file_path: str): yaml.dump(config, file) logger.info(f"{file_path} has been created.") + def load_config(file_path: str) -> dict: """Loads a given config file""" try: diff --git a/src/whispr/utils/process.py b/src/whispr/utils/process.py index 501f8bd..9bbb8d9 100644 --- a/src/whispr/utils/process.py +++ b/src/whispr/utils/process.py @@ -4,9 +4,10 @@ from whispr.logging import logger + def execute_command(command: tuple, no_env: bool, secrets: dict): """Executes a Unix/Windows command. - Arg: `no_env` decides whether secrets are passed vai environment or K:V pairs in command arguments. + Arg: `no_env` decides whether secrets are passed vai environment or K:V pairs in command arguments. """ if not secrets: secrets = {} @@ -16,9 +17,7 @@ def execute_command(command: tuple, no_env: bool, secrets: dict): if no_env: # Pass as --env K=V format (secure) - usr_command.extend([ - f"{k}={v}" for k,v in secrets.items() - ]) + usr_command.extend([f"{k}={v}" for k, v in secrets.items()]) else: # Pass via environment (slightly insecure) os.environ.update(secrets) diff --git a/tests/test_factory.py b/tests/test_factory.py index 1594373..bf27c99 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -83,7 +83,8 @@ def test_get_azure_vault_client_no_url(self): with self.assertRaises(ValueError): VaultFactory.get_vault(**config) - def test_get_gcp_vault_client(self): + @patch("google.cloud.secretmanager.SecretManagerServiceClient") + def test_get_gcp_vault_client(self, mock_client): """Test GCPVault client""" config = { "vault": "gcp", @@ -94,6 +95,7 @@ def test_get_gcp_vault_client(self): } vault_instance = VaultFactory.get_vault(**config) self.assertIsInstance(vault_instance, GCPVault) + mock_client.assert_called_once() def test_get_gcp_vault_client_no_project_id(self): """Test GCPVault raises exception when project_id is not defined in config""" diff --git a/tests/test_gcp.py b/tests/test_gcp.py index 71c70c0..e1c2d91 100644 --- a/tests/test_gcp.py +++ b/tests/test_gcp.py @@ -1,12 +1,10 @@ """Tests for GCP module""" import unittest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock import google.api_core.exceptions -import structlog -from whispr.vault import SimpleVault from whispr.gcp import GCPVault diff --git a/tests/test_io_utils.py b/tests/test_io_utils.py index ee9e619..0b02c0f 100644 --- a/tests/test_io_utils.py +++ b/tests/test_io_utils.py @@ -1,4 +1,3 @@ - import os import yaml import unittest @@ -6,6 +5,7 @@ from unittest.mock import MagicMock, patch, mock_open from whispr.utils.io import write_to_yaml_file, load_config + class IOUtilsTestCase(unittest.TestCase): """Unit tests for the file utilities: write_to_yaml_file and load_config.""" @@ -18,7 +18,9 @@ def setUp(self): @patch("whispr.utils.io.logger", new_callable=lambda: MagicMock()) @patch("builtins.open", new_callable=mock_open) @patch("os.path.exists", return_value=False) - def test_write_to_yaml_file_creates_file(self, mock_exists, mock_open_file, mock_logger): + def test_write_to_yaml_file_creates_file( + self, mock_exists, mock_open_file, mock_logger + ): """Test that write_to_yaml_file creates a new file and writes config data as YAML.""" write_to_yaml_file(self.config, self.file_path) @@ -29,7 +31,9 @@ def test_write_to_yaml_file_creates_file(self, mock_exists, mock_open_file, mock @patch("whispr.utils.io.logger", new_callable=lambda: MagicMock()) @patch("builtins.open", new_callable=mock_open) @patch("os.path.exists", return_value=True) - def test_write_to_yaml_file_does_not_overwrite_existing_file(self, mock_exists, mock_open_file, mock_logger): + def test_write_to_yaml_file_does_not_overwrite_existing_file( + self, mock_exists, mock_open_file, mock_logger + ): """Test that write_to_yaml_file does not overwrite an existing file.""" write_to_yaml_file(self.config, self.file_path) diff --git a/tests/test_process_utils.py b/tests/test_process_utils.py index b0861c6..ac01dad 100644 --- a/tests/test_process_utils.py +++ b/tests/test_process_utils.py @@ -23,22 +23,30 @@ def test_execute_command_with_no_env(self, mock_subprocess_run, mock_logger): execute_command(self.command, self.no_env, self.secrets) expected_command = ["echo", "Hello", "API_KEY=123456"] - mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True) + mock_subprocess_run.assert_called_once_with( + expected_command, env=os.environ, shell=False, check=True + ) @patch("whispr.utils.process.logger", new_callable=lambda: MagicMock()) @patch("subprocess.run") @patch("os.environ.update") - def test_execute_command_with_env(self, mock_env_update, mock_subprocess_run, mock_logger): + 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) mock_env_update.assert_called_once_with(self.secrets) expected_command = ["echo", "Hello"] - mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True) + mock_subprocess_run.assert_called_once_with( + expected_command, env=os.environ, shell=False, check=True + ) @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(self, mock_subprocess_run, mock_logger): + def test_execute_command_called_process_error( + self, mock_subprocess_run, mock_logger + ): """Test execute_command handles CalledProcessError and logs an error message.""" with self.assertRaises(subprocess.CalledProcessError): execute_command(self.command, no_env=True, secrets=self.secrets) @@ -54,5 +62,7 @@ def test_execute_command_without_secrets(self, mock_subprocess_run, mock_logger) execute_command(self.command, no_env=True, secrets={}) expected_command = ["echo", "Hello"] - mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True) + mock_subprocess_run.assert_called_once_with( + expected_command, env=os.environ, shell=False, check=True + ) mock_logger.error.assert_not_called() diff --git a/tests/test_vault_utils.py b/tests/test_vault_utils.py index bd74bbe..fe066f7 100644 --- a/tests/test_vault_utils.py +++ b/tests/test_vault_utils.py @@ -43,18 +43,28 @@ def test_fetch_secrets_missing_config(self, mock_logger): ) @patch("whispr.utils.vault.logger", new_callable=lambda: MagicMock()) - @patch("whispr.utils.vault.VaultFactory.get_vault", side_effect=ValueError("Invalid vault type")) + @patch( + "whispr.utils.vault.VaultFactory.get_vault", + side_effect=ValueError("Invalid vault type"), + ) def test_fetch_secrets_invalid_vault(self, mock_get_vault, mock_logger): """Test fetch_secrets logs an error if the vault factory raises a ValueError.""" - result = fetch_secrets({ - "vault": "UNKOWN", - "secret_name": "test_secret", - }) + result = fetch_secrets( + { + "vault": "UNKOWN", + "secret_name": "test_secret", + } + ) self.assertEqual(result, {}) - mock_logger.error.assert_called_once_with("Error creating vault instance: Invalid vault type") + mock_logger.error.assert_called_once_with( + "Error creating vault instance: Invalid vault type" + ) - @patch("whispr.utils.vault.dotenv_values", return_value={"API_KEY": "", "OTHER_KEY": ""}) + @patch( + "whispr.utils.vault.dotenv_values", + return_value={"API_KEY": "", "OTHER_KEY": ""}, + ) @patch("whispr.utils.vault.logger", new_callable=lambda: MagicMock()) def test_get_filled_secrets_partial_match(self, mock_logger, mock_dotenv_values): """Test get_filled_secrets fills only matching secrets from vault_secrets."""