Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/23: add secret command group with get & gen-random sub-commands #25

Merged
merged 12 commits into from
Jan 15, 2025
Merged
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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

Expand Down Expand Up @@ -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 '<your_app_command_with_args>'` (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
Expand All @@ -117,25 +143,14 @@ 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
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:

* HashiCorp Vault
* 1Password Vault
* K8s secret patching
* Container patching (docker)
* Increase test coverage
4 changes: 3 additions & 1 deletion clean_build.sh
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/whispr/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version = "0.5.0"
version = "0.6.0"
137 changes: 132 additions & 5 deletions src/whispr/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""whispr CLI entrypoint"""

import os
import json

import click

Expand All @@ -11,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 <vault>` to create a configuration.

Availble values for vault: ["aws", "azure", "gcp"]
Availble values for <vault>: ["aws", "azure", "gcp"]
"""
pass

Expand All @@ -37,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.")
Expand Down Expand Up @@ -78,5 +93,117 @@ def run(command):
cli.add_command(init)
cli.add_command(run)


# Secret group
@click.group()
def secret():
"""`whispr secret` group manages a secret lifecycle.

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
# 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


# Add secret command group
cli.add_command(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
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

print(json.dumps(vault_secrets, indent=4))


@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 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:
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()
2 changes: 1 addition & 1 deletion src/whispr/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions src/whispr/utils/crypto.py
Original file line number Diff line number Diff line change
@@ -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))
60 changes: 60 additions & 0 deletions src/whispr/utils/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,63 @@ 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 the 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=<val> option."
)
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(
"No region option provided to get-secret sub command for AWS Vault. Use --region=<val> 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=<val> option."
)
return {}

config = {
"secret_name": secret_name,
"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=<val> 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)

return vault_secrets
Loading