Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 47 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,77 +15,77 @@

## Quick Start

> **Note:** rh-envault is not yet published to PyPI. Install directly from GitHub.
> **Note:** envault is not yet published to PyPI. Install directly from GitHub.

```bash
pip install git+https://github.com/Coding-Dev-Tools/envault.git

# Initialize a project
rh-envault init my-project
envault init my-project

# Diff environments
rh-envault diff dev prod
envault diff dev prod

# Sync staging → prod
rh-envault sync staging prod
envault sync staging prod

# Rotate a secret
rh-envault rotate DB_PASSWORD
envault rotate DB_PASSWORD
```

## Commands

### `rh-envault init <project>`
### `envault init <project>`

Initialize a `.envault.yml` config file with sensible defaults.

```bash
rh-envault init my-project
envault init my-project
```

### `rh-envault diff <source> <target>`
### `envault diff <source> <target>`

Diff environment variables between two environments or `.env` files. Shows keys that are:
- Only in source
- Only in target
- Present in both but with different values

```bash
rh-envault diff dev staging
rh-envault diff prod staging
rh-envault diff-files .env.dev .env.prod
envault diff dev staging
envault diff prod staging
envault diff-files .env.dev .env.prod
```

### `rh-envault sync <source> <target>`
### `envault sync <source> <target>`

Sync environment variables from one environment to another with conflict resolution strategies.

```bash
# Sync staging → prod (source values win conflicts)
rh-envault sync staging prod
envault sync staging prod

# Dry run first
rh-envault sync staging prod --dry-run
envault sync staging prod --dry-run

# Keep target values on conflict
rh-envault sync staging prod --strategy target_wins
envault sync staging prod --strategy target_wins

# Delete keys in target that don't exist in source
rh-envault sync staging prod --allow-delete
envault sync staging prod --allow-delete

# Skip certain keys
rh-envault sync staging prod --skip DB_HOST --skip DB_PORT
envault sync staging prod --skip DB_HOST --skip DB_PORT
```

### `rh-envault rotate <key>`
### `envault rotate <key>`

Rotate a single environment variable with an auto-generated cryptographically secure value.

```bash
rh-envault rotate DB_PASSWORD
rh-envault rotate API_KEY --env prod
rh-envault rotate JWT_SECRET --length 64 --dry-run --show
rh-envault rotate-all --env prod
envault rotate DB_PASSWORD
envault rotate API_KEY --env prod
envault rotate JWT_SECRET --length 64 --dry-run --show
envault rotate-all --env prod
```

Smart rotation infers the type of secret:
Expand All @@ -95,41 +95,41 @@ Smart rotation infers the type of secret:
- `WEBHOOK_SECRET` → long hex key
- Everything else → 32-char random string

### `rh-envault store`
### `envault store`

Manage secret store integrations — read, write, and list secrets from external stores.

```bash
rh-envault store list
rh-envault store list --prefix /production/
rh-envault store get DB_PASSWORD --store my-vault
rh-envault store set DB_PASSWORD new_value --store my-vault
rh-envault store delete DB_PASSWORD --store my-vault
envault store list
envault store list --prefix /production/
envault store get DB_PASSWORD --store my-vault
envault store set DB_PASSWORD new_value --store my-vault
envault store delete DB_PASSWORD --store my-vault
```

### `rh-envault audit`
### `envault audit`

View the audit log of all diff, sync, and rotate operations.

```bash
rh-envault audit
rh-envault audit --key DB_PASSWORD
rh-envault audit --action rotate --limit 100
envault audit
envault audit --key DB_PASSWORD
envault audit --action rotate --limit 100
```

### `rh-envault serve`
### `envault serve`

Start an HTTP server that exposes decrypted secrets as a JSON API — ideal for MCP server sidecars, CI/CD pipelines, and AI agent runtimes.

```bash
# Start the secrets API on port 8080 (default)
rh-envault serve
envault serve

# Custom port, host, and API key
rh-envault serve --port 3000 --host 0.0.0.0 --api-key my-bearer-token
envault serve --port 3000 --host 0.0.0.0 --api-key <YOUR_API_KEY>

# Use a named store from config
rh-envault serve --store production-secrets
envault serve --store production-secrets
```

**Endpoints:**
Expand All @@ -147,13 +147,13 @@ rh-envault serve --store production-secrets

```bash
# Fetch secrets with curl
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets
curl -H "Authorization: Bearer ***" http://localhost:8080/secrets

# Filter by prefix
curl -H "Authorization: Bearer my-token" "http://localhost:8080/secrets?prefix=STRIPE"
curl -H "Authorization: Bearer ***" "http://localhost:8080/secrets?prefix=STRIPE"

# Get a specific secret
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets/DB_PASSWORD
curl -H "Authorization: Bearer ***" http://localhost:8080/secrets/DB_PASSWORD
```

## Features
Expand Down Expand Up @@ -238,23 +238,23 @@ audit_log_path: .envault-audit.log

| Store | Package | Install (from GitHub) |
|-------|---------|----------------------|
| AWS SSM | `boto3` | `pip install "rh-envault[awsssm] @ git+https://..."` |
| HashiCorp Vault | `hvac` | `pip install "rh-envault[vault] @ git+https://..."` |
| Doppler | `requests` | `pip install "rh-envault[doppler] @ git+https://..."` |
| 1Password | `onepasswordconnectsdk` | `pip install "rh-envault[onepassword] @ git+https://..."` |
| AWS SSM | `boto3` | `pip install "envault[awsssm] @ git+https://..."` |
| HashiCorp Vault | `hvac` | `pip install "envault[vault] @ git+https://..."` |
| Doppler | `requests` | `pip install "envault[doppler] @ git+https://..."` |
| 1Password | `onepasswordconnectsdk` | `pip install "envault[onepassword] @ git+https://..."` |

## CI/CD Integration

```bash
# Block deployment if production has secrets that staging doesn't
rh-envault diff staging prod --fail-on-missing
envault diff staging prod --fail-on-missing

# Rotate a secret and sync to all environments
rh-envault rotate DB_PASSWORD --env staging
rh-envault sync staging prod
envault rotate DB_PASSWORD --env staging
envault sync staging prod

# Audit before deployment
rh-envault audit --action rotate --limit 20
envault audit --action rotate --limit 20
```

## Storage
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dev = [
"responses>=0.24.0",
"ruff>=0.4.0",
]
license = ["revenueholdings-license>=0.1.0"]
all = [
"hvac>=2.0.0",
"boto3>=1.28.0",
Expand Down
31 changes: 27 additions & 4 deletions src/envault/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@

from __future__ import annotations

import sys
from pathlib import Path

# Ensure UTF-8 output on Windows consoles that default to cp1252
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass

import typer
from rich.console import Console
from rich.prompt import Confirm
from rich.table import Table
from envault import __version__
from envault.audit import AuditLogger
from envault.backup import backup_env_file, format_backup_list, list_backups, restore_backup
Expand All @@ -15,10 +29,14 @@
from envault.serve import run_server
from envault.stores import get_store
from envault.sync import sync_env_files
from pathlib import Path
from rich.console import Console
from rich.prompt import Confirm
from rich.table import Table

try:
from revenueholdings_license import require_license
except ImportError:
import warnings
warnings.warn("revenueholdings-license not installed; license checks skipped", stacklevel=2)
def require_license(product: str) -> None: # type: ignore[misc]
pass

app = typer.Typer(
name="envault",
Expand All @@ -29,6 +47,11 @@
err_console = Console(stderr=True)


@app.callback()
def main_callback():
require_license("envault")


def load_config(config_path: str = "") -> EnvaultConfig:
"""Load config, optionally from a specific path."""
path = config_path if config_path else ".envault.yml"
Expand Down
12 changes: 3 additions & 9 deletions src/envault/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from __future__ import annotations

import io
import json
import os
from dotenv import dotenv_values
from pathlib import Path

Expand All @@ -18,14 +18,8 @@ def load_env_file(path: str | Path) -> dict[str, str]:

def load_env_content(content: str) -> dict[str, str]:
"""Load environment variables from a string content."""
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write(content)
tmp = f.name
try:
return {k: v for k, v in dotenv_values(tmp).items() if k is not None and v is not None}
finally:
os.unlink(tmp)
stream = io.StringIO(content)
return {k: v for k, v in dotenv_values(stream=stream).items() if k is not None and v is not None}


class EnvDiffResult:
Expand Down
7 changes: 5 additions & 2 deletions src/envault/rotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,11 @@ def rotate_env_var(
content = f.read()

import re
# Match KEY=value or KEY="value" or KEY='value'
pattern = re.compile(rf"^{re.escape(key)}\s*=\s*['\"]?.*?['\"]?\s*$", re.MULTILINE)
# Match KEY=value or KEY="..." or KEY='...' — anchored to full value
pattern = re.compile(
rf"^{re.escape(key)}\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\n]*)$",
re.MULTILINE,
)

# Escape new value for the .env file
if any(c in new_value for c in " #'\"\n\t"):
Expand Down