Skip to content

Commit

Permalink
Merge pull request #24 from cybrota/feat/22
Browse files Browse the repository at this point in the history
feat/4: Add AWS Default region support
  • Loading branch information
narenaryan authored Dec 26, 2024
2 parents 6e1e432 + 364e6b8 commit ebf87fe
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 46 deletions.
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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 '<your_app_command_with_args>'` (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": "<your_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 '<your_app_command_with_args>'` (mind the single quotes around command) to inject your secrets before starting the subprocess.

Examples:
```bash
Expand All @@ -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)
2 changes: 1 addition & 1 deletion clean_build.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/whispr/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version = "0.4.0"
version = "0.5.0"
1 change: 1 addition & 0 deletions src/whispr/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
37 changes: 35 additions & 2 deletions src/whispr/factory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Vault factory"""

import os

import boto3
import botocore.exceptions
import structlog
Expand All @@ -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:
"""
Expand All @@ -26,24 +54,29 @@ 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")
logger: structlog.BoundLogger = kwargs.get("logger")
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:
Expand Down
5 changes: 4 additions & 1 deletion src/whispr/utils/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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."
Expand Down
Empty file added tests/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions tests/test_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
89 changes: 88 additions & 1 deletion tests/test_factory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from functools import wraps
import os
import unittest
from unittest.mock import MagicMock, patch
Expand All @@ -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"""
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 = {
Expand Down
12 changes: 11 additions & 1 deletion tests/test_process_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,24 @@ 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"]
mock_subprocess_run.assert_called_once_with(
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(
Expand Down
27 changes: 0 additions & 27 deletions usage-guides/programmatic-access.md

This file was deleted.

0 comments on commit ebf87fe

Please sign in to comment.