Skip to content

Commit

Permalink
Merge pull request #19 from narenaryan/feat/1
Browse files Browse the repository at this point in the history
feat: add support for AWS SSO profile in config
  • Loading branch information
narenaryan authored Dec 8, 2024
2 parents 0eac842 + a5c90ee commit 6e1e432
Show file tree
Hide file tree
Showing 13 changed files with 187 additions and 40 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
name: Semgrep CI Scan
name: Run security scan

on:
push:
branches:
- main
pull_request:
branches:
- '*'
- "*"

jobs:
semgrep-scan:
Expand All @@ -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: |
Expand Down
4 changes: 4 additions & 0 deletions clean_build.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/whispr/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version = "0.3.0"
version = "0.4.0"
18 changes: 10 additions & 8 deletions src/whispr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,35 @@
)
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)


@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
Expand Down
16 changes: 13 additions & 3 deletions src/whispr/factory.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Vault factory"""

import os

import boto3
import botocore.exceptions
import structlog
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
Expand All @@ -21,19 +20,30 @@ 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.
:param kwargs: Any additional parameters required for specific vault clients.
: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:
Expand Down
2 changes: 2 additions & 0 deletions src/whispr/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

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):
with open(file_path, "w", encoding="utf-8") as file:
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:
Expand Down
7 changes: 3 additions & 4 deletions src/whispr/utils/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions tests/test_aws.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
110 changes: 110 additions & 0 deletions tests/test_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import os
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()
os.environ["AWS_DEFAULT_REGION"] = "us-west-2"

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)

@patch("google.cloud.secretmanager.SecretManagerServiceClient")
def test_get_gcp_vault_client(self, mock_client):
"""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)
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"""
config = {
"vault": "gcp",
"env": ".env",
"secret_name": "dummy_secret",
"logger": self.mock_logger,
}

with self.assertRaises(ValueError):
VaultFactory.get_vault(**config)
4 changes: 1 addition & 3 deletions tests/test_gcp.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
10 changes: 7 additions & 3 deletions tests/test_io_utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@

import os
import yaml
import unittest

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."""

Expand All @@ -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)

Expand All @@ -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)

Expand Down
20 changes: 15 additions & 5 deletions tests/test_process_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Loading

0 comments on commit 6e1e432

Please sign in to comment.