
Env Secrets Manager
- 82 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Env & Secrets Manager is a Claude skill covering the environment and secrets lifecycle: .env scaffolding, leak detection in git history, startup validation, rotation playbooks, and integration with Vault, AWS SSM, 1Passw
About
Env & Secrets Manager covers the full environment and secrets lifecycle. It scaffolds .env files, auto-generates .env.example stripped of secrets, validates required variables at startup, scans git history for leaked credentials, and provides credential-rotation playbooks. It integrates with HashiCorp Vault, AWS SSM, 1Password CLI, and Doppler. A developer uses it when setting up a project, scanning for leaked secrets, or rotating credentials after an incident.
- Scaffolds .env structure and auto-generates .env.example that strips sensitive values, with startup validation
- Scans git history and staged files for leaked secrets and blocks commits via pre-commit hooks
- Provides credential rotation playbooks and integrations for Vault, AWS SSM, 1Password CLI, and Doppler
Env Secrets Manager by the numbers
- 82 all-time installs (skills.sh)
- Ranked #1,086 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
env-secrets-manager capabilities & compatibility
- Capabilities
- docker development · data breach response
- Works with
- aws · github
- Use cases
- security audit · devops
- Pricing
- Free
What env-secrets-manager says it does
Complete environment and secrets management lifecycle. Covers .env file scaffolding, validation scripts, secret leak detection in git history, credential rotation playbooks
integration with HashiCorp Vault, AWS SSM, 1Password CLI, and Doppler.
Regex scan of git history for exposed credentials
npx skills add https://github.com/borghei/claude-skills --skill env-secrets-managerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Manage the .env and secrets lifecycle: scaffolding, leak detection, validation, and credential rotation.
Who is it for?
Developers hardening secret handling: scaffolding env files, catching leaked credentials in git, and rotating secrets after an incident.
Skip if: Being a runtime secrets vault itself; it orchestrates .env hygiene and integrates with Vault/SSM/1Password/Doppler rather than replacing them.
When should I use this skill?
Setting up a new project, scanning for accidentally committed secrets, or rotating leaked credentials after an incident.
What you get
A structured .env layout with generated .env.example, startup validation, git-history leak scanning, and documented rotation playbooks.
- structured .env and .env.example
- startup validation script
- git-history secret leak scan
By the numbers
- 4 secret-manager integrations (Vault, AWS SSM, 1Password CLI, Doppler)
- APP_SECRET/JWT secret minimum 32 chars
Files
Env & Secrets Manager
Tier: POWERFUL Category: Engineering / Security Maintainer: Claude Skills Team
Overview
Complete environment variable and secrets management lifecycle: .env file structure across dev/staging/production, .env.example auto-generation that strips sensitive values, required-variable validation at startup, secret leak detection in git history, credential rotation playbooks, environment drift detection, and integration with HashiCorp Vault, AWS SSM, 1Password CLI, and Doppler.
Keywords
secrets management, environment variables, .env, secret rotation, HashiCorp Vault, AWS SSM, 1Password, Doppler, secret leak detection, credential rotation, environment drift
Core Capabilities
1. .env Lifecycle Management
- Structured .env layout with categorized sections
- Auto-generation of .env.example from .env (strips sensitive values)
- Environment-specific files (.env.local, .env.staging, .env.production)
- Validation scripts that fail fast on missing required variables
2. Secret Leak Detection
- Regex scan of git history for exposed credentials
- Pre-commit hook integration to block secret commits
- Pattern matching for API keys, tokens, passwords, private keys
- Working tree and staged file scanning
3. Credential Rotation
- Step-by-step rotation playbooks per secret type
- Scope analysis (find everywhere a secret is used)
- Zero-downtime rotation with dual-read period
- Post-rotation verification and monitoring
4. Secret Manager Integration
- HashiCorp Vault KV v2 with OIDC authentication
- AWS SSM Parameter Store with KMS encryption
- 1Password CLI with template injection
- Doppler with project/config management
When to Use
- Setting up a new project — scaffold .env.example and validation
- Before every commit — scan for accidentally staged secrets
- Post-incident — rotate leaked credentials systematically
- Onboarding developers — provide complete environment setup
- Auditing — detect environment drift between staging and production
- Compliance — demonstrate secret management practices
.env File Structure
Canonical Layout
# ─── Application ───────────────────────────────────
APP_NAME=myapp
APP_ENV=development # development | staging | production
APP_PORT=3000
APP_URL=http://localhost:3000 # REQUIRED: public base URL
APP_SECRET= # REQUIRED: min 32 chars, used for signing
# ─── Database ──────────────────────────────────────
DATABASE_URL= # REQUIRED: full connection string
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10
DATABASE_SSL=false # true in staging/production
# ─── Authentication ────────────────────────────────
AUTH_JWT_SECRET= # REQUIRED: min 32 chars
AUTH_JWT_EXPIRY=3600 # seconds
AUTH_REFRESH_SECRET= # REQUIRED: min 32 chars
AUTH_REFRESH_EXPIRY=604800 # 7 days in seconds
# ─── Third-Party Services ─────────────────────────
STRIPE_SECRET_KEY= # REQUIRED in production
STRIPE_WEBHOOK_SECRET= # REQUIRED in production
STRIPE_PUBLISHABLE_KEY= # REQUIRED (public, safe to expose)
SENDGRID_API_KEY= # REQUIRED for email features
SENTRY_DSN= # Optional: error tracking
# ─── Storage ───────────────────────────────────────
AWS_ACCESS_KEY_ID= # Prefer IAM roles in production
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-1
S3_BUCKET=
# ─── Monitoring ────────────────────────────────────
DD_API_KEY=
LOG_LEVEL=debug # debug | info | warn | errorFile Hierarchy
.env.example → Committed to git. Keys only, no values. Safe defaults noted.
.env → Local development. NEVER committed. In .gitignore.
.env.local → Local overrides. NEVER committed.
.env.test → Test environment. May be committed if no secrets.
.env.staging → Reference only. Actual values in secret manager.
.env.production → NEVER exists on disk. Pulled from secret manager at runtime..gitignore Patterns (Required)
# Environment files
.env
.env.local
.env.*.local
.env.development
.env.staging
.env.production
# Secret files
*.pem
*.key
*.p12
*.pfx
secrets.json
secrets.yaml
credentials.json
service-account*.json
# Cloud credentials
.aws/credentials
.gcloud/
# Terraform state (may contain secrets)
*.tfstate
*.tfstate.backupStartup Validation Script
#!/usr/bin/env python3
"""Validate required environment variables at application startup."""
import os
import sys
import re
REQUIRED_VARS = {
"APP_SECRET": {"min_length": 32, "description": "Application signing secret"},
"DATABASE_URL": {"pattern": r"^postgres(ql)?://", "description": "PostgreSQL connection string"},
"AUTH_JWT_SECRET": {"min_length": 32, "description": "JWT signing secret"},
}
REQUIRED_IN_PRODUCTION = {
"STRIPE_SECRET_KEY": {"pattern": r"^sk_(live|test)_", "description": "Stripe secret key"},
"STRIPE_WEBHOOK_SECRET": {"pattern": r"^whsec_", "description": "Stripe webhook secret"},
"SENDGRID_API_KEY": {"pattern": r"^SG\.", "description": "SendGrid API key"},
"SENTRY_DSN": {"pattern": r"^https://", "description": "Sentry DSN"},
}
def validate() -> list[str]:
errors = []
env = os.environ.get("APP_ENV", "development")
vars_to_check = dict(REQUIRED_VARS)
if env == "production":
vars_to_check.update(REQUIRED_IN_PRODUCTION)
for var_name, rules in vars_to_check.items():
value = os.environ.get(var_name, "")
if not value:
errors.append(f"MISSING: {var_name} — {rules['description']}")
continue
if "min_length" in rules and len(value) < rules["min_length"]:
errors.append(
f"TOO SHORT: {var_name} is {len(value)} chars, need {rules['min_length']}+"
)
if "pattern" in rules and not re.match(rules["pattern"], value):
errors.append(
f"INVALID FORMAT: {var_name} does not match expected pattern"
)
return errors
if __name__ == "__main__":
errors = validate()
if errors:
print("Environment validation FAILED:", file=sys.stderr)
for e in errors:
print(f" {e}", file=sys.stderr)
sys.exit(1)
print("Environment validation passed.")Secret Leak Detection
Git History Scanner
#!/bin/bash
# Scan git history for leaked secrets
echo "Scanning git history for potential secrets..."
PATTERNS=(
'AKIA[0-9A-Z]{16}' # AWS Access Key
'AIza[0-9A-Za-z\-_]{35}' # Google API Key
'sk_(live|test)_[0-9a-zA-Z]{24,}' # Stripe Secret Key
'ghp_[0-9a-zA-Z]{36}' # GitHub Personal Access Token
'glpat-[0-9a-zA-Z\-]{20,}' # GitLab Personal Access Token
'xoxb-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24}' # Slack Bot Token
'SG\.[0-9A-Za-z\-_]{22}\.[0-9A-Za-z\-_]{43}' # SendGrid API Key
'-----BEGIN (RSA |EC )?PRIVATE KEY-----' # Private Keys
'password\s*=\s*["\x27][^"\x27]{8,}["\x27]' # Hardcoded passwords
)
FOUND=0
for pattern in "${PATTERNS[@]}"; do
MATCHES=$(git log -p --all -S "$pattern" --format="%H %an %ad %s" 2>/dev/null | head -20)
if [ -n "$MATCHES" ]; then
echo ""
echo "FOUND pattern: $pattern"
echo "$MATCHES"
FOUND=$((FOUND + 1))
fi
done
if [ "$FOUND" -gt 0 ]; then
echo ""
echo "WARNING: Found $FOUND potential secret patterns in git history."
echo "Run 'git filter-repo' or BFG Repo-Cleaner to remove them."
exit 1
else
echo "No secrets detected in git history."
fiPre-Commit Hook
#!/bin/bash
# .git/hooks/pre-commit — block commits containing secrets
PATTERNS=(
'AKIA[0-9A-Z]{16}'
'sk_(live|test)_[0-9a-zA-Z]{24,}'
'ghp_[0-9a-zA-Z]{36}'
'-----BEGIN (RSA |EC )?PRIVATE KEY-----'
)
FILES=$(git diff --cached --name-only --diff-filter=ACM)
for file in $FILES; do
for pattern in "${PATTERNS[@]}"; do
if git diff --cached -- "$file" | grep -qE "$pattern"; then
echo "BLOCKED: Potential secret detected in $file"
echo "Pattern: $pattern"
echo "Remove the secret and try again."
exit 1
fi
done
doneCredential Rotation Playbook
Step 1: Scope the Secret
# Find everywhere a secret is referenced
SECRET_NAME="STRIPE_SECRET_KEY"
# In code
grep -r "$SECRET_NAME" src/ lib/ app/ --include="*.ts" --include="*.py" -l
# In CI/CD
grep -r "$SECRET_NAME" .github/ .gitlab-ci.yml docker-compose.yml -l
# In infrastructure
grep -r "$SECRET_NAME" terraform/ k8s/ helm/ -l 2>/dev/null
# In secret managers
vault kv get -field=$SECRET_NAME secret/myapp/prod 2>/dev/null
aws ssm get-parameter --name "/myapp/prod/$SECRET_NAME" 2>/dev/null
doppler secrets get $SECRET_NAME --project myapp --config prod 2>/dev/nullStep 2: Generate New Secret
# Generic secret (32 bytes, base64)
openssl rand -base64 32
# JWT secret (64 bytes for HS256)
openssl rand -base64 64
# API key format (alphanumeric)
openssl rand -hex 32Step 3: Dual-Write Period
Timeline:
─────────────────────────────────────────────────────
T+0: Generate new secret
T+1: Deploy code that accepts BOTH old and new secrets
T+2: Update secret in ALL locations to new value
T+3: Verify all services work with new secret
T+4: Deploy code that accepts ONLY new secret
T+5: Invalidate/revoke old secret
T+6: Monitor for 24 hours for any auth failures
─────────────────────────────────────────────────────Step 4: Verify and Monitor
# Check for auth failures in logs (24 hours after rotation)
# Replace with your actual log query
grep -i "unauthorized\|auth.*fail\|invalid.*token" /var/log/app/*.log | tail -20
# Verify new credentials work
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $NEW_TOKEN" \
https://api.myapp.com/healthSecret Manager Integration
HashiCorp Vault
# Authenticate via OIDC
export VAULT_ADDR="https://vault.company.com"
vault login -method=oidc
# Store secrets
vault kv put secret/myapp/prod \
DATABASE_URL="postgres://user:pass@host/db" \
APP_SECRET="$(openssl rand -base64 32)" \
STRIPE_SECRET_KEY="sk_live_..."
# Read secrets into environment
eval $(vault kv get -format=json secret/myapp/prod | \
jq -r '.data.data | to_entries[] | "export \(.key)=\(.value|@sh)"')
# Rotate a single secret
vault kv patch secret/myapp/prod \
APP_SECRET="$(openssl rand -base64 32)"AWS SSM Parameter Store
# Store as encrypted parameter
aws ssm put-parameter \
--name "/myapp/prod/DATABASE_URL" \
--value "postgres://..." \
--type "SecureString" \
--key-id "alias/myapp-secrets" \
--overwrite
# Read all parameters for an environment
aws ssm get-parameters-by-path \
--path "/myapp/prod/" \
--with-decryption \
--query "Parameters[*].[Name,Value]" \
--output textDoppler
# Set up project
doppler setup --project myapp --config prod
# Run with secrets injected (recommended for production)
doppler run -- node server.js
# Download for local dev
doppler secrets download --no-file --format env > .env.localEnvironment Drift Detection
#!/bin/bash
# Compare environment variable keys between staging and production
STAGING_KEYS=$(doppler secrets --project myapp --config staging --format json | \
jq -r 'keys[]' | sort)
PROD_KEYS=$(doppler secrets --project myapp --config prod --format json | \
jq -r 'keys[]' | sort)
ONLY_STAGING=$(comm -23 <(echo "$STAGING_KEYS") <(echo "$PROD_KEYS"))
ONLY_PROD=$(comm -13 <(echo "$STAGING_KEYS") <(echo "$PROD_KEYS"))
if [ -n "$ONLY_STAGING" ]; then
echo "DRIFT: Keys in STAGING but NOT in PROD:"
echo "$ONLY_STAGING" | sed 's/^/ /'
fi
if [ -n "$ONLY_PROD" ]; then
echo "DRIFT: Keys in PROD but NOT in STAGING:"
echo "$ONLY_PROD" | sed 's/^/ /'
fi
[ -z "$ONLY_STAGING" ] && [ -z "$ONLY_PROD" ] && echo "No drift detected."Common Pitfalls
- Committing .env to git — add
.envto .gitignore on day 1; use pre-commit hooks as a safety net - Echoing secrets in CI logs — never
echo $SECRET; mask variables in CI settings - Rotating in only one location — secrets exist in CI, hosting, Docker, K8s; update ALL locations
- Weak secrets —
APP_SECRET=mysecretis not a secret; useopenssl rand -base64 32 - Shared secrets across environments — dev and prod must have different secrets, always
- No monitoring after rotation — watch for auth failures for 24 hours after rotating credentials
- .env.example with real values — example files are public; strip everything sensitive
- Long-lived credentials — prefer short-lived tokens (OIDC, instance roles) over permanent API keys
Best Practices
1. Secret manager is source of truth — .env files are for local dev only; never in production 2. Rotate on a schedule — quarterly minimum for long-lived keys, not just after incidents 3. Principle of least privilege — each service gets its own API key with minimal permissions 4. Validate at startup — fail fast on missing required variables before serving traffic 5. Never log secrets — add middleware that redacts known secret patterns from log output 6. Use short-lived credentials — prefer OIDC/instance roles over long-lived access keys 7. Audit access — log every secret read in Vault/SSM; alert on anomalous access patterns 8. Document rotation playbooks — write them before an incident, not during one
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Startup validation fails with MISSING for a set variable | Variable is set in .env but the app reads from a different file (e.g., .env.local overrides it to empty) | Check file hierarchy load order; ensure the correct .env.* file is loaded and no override blanks the value |
| Pre-commit hook passes but CI detects a leaked secret | Pre-commit patterns list is out of date or does not cover the token format CI scans for | Sync the regex pattern list between the pre-commit hook and CI scanner; add the missing pattern |
vault kv get returns "permission denied" | OIDC token expired or the Vault policy does not grant read access to the target path | Re-authenticate with vault login -method=oidc and verify the policy includes read capability on the secret path |
| Environment drift detection shows false positives | One environment uses a prefix convention (e.g., NEXT_PUBLIC_) that the other does not | Add an exclusion list of known environment-specific keys to the drift script |
| Secret rotation causes service outage | Code was deployed without the dual-read period; only the new secret is accepted immediately | Always deploy the dual-read code change first, then update the secret value, then remove old-secret support |
.env.example accidentally contains real credentials | Developer copied .env to .env.example without stripping values | Run the auto-generation script to rebuild .env.example from .env with values stripped; add a CI check that .env.example values match safe defaults only |
AWS SSM put-parameter fails with AccessDeniedException | IAM role lacks ssm:PutParameter or kms:Encrypt permissions for the target key | Attach the required IAM policy granting ssm:PutParameter and kms:Encrypt on the KMS key alias used for SecureString |
Success Criteria
- Zero secrets in git history — secret leak scanner reports 0 findings across all branches
- 100% startup validation coverage — every required variable is declared in the validation script; no production deploy starts with missing vars
- Rotation completed within SLA — credential rotation finishes within 4 hours of incident detection, including dual-write period and verification
- Environment drift below 5% — staging and production variable key sets differ by no more than 5% (intentional differences documented)
- Pre-commit hook adoption at 100% — every contributor has the secret-blocking pre-commit hook installed and active
- Quarterly rotation compliance — all long-lived credentials are rotated at least once per quarter with audit trail in the secret manager
- Post-rotation monitoring green — zero authentication failures attributed to stale credentials in the 24-hour window after each rotation
Scope & Limitations
This skill covers:
.envfile scaffolding, hierarchy, and validation for any language/framework- Secret leak detection in git history, staged files, and working tree
- Credential rotation playbooks with zero-downtime dual-read strategy
- Integration patterns for HashiCorp Vault, AWS SSM, 1Password CLI, and Doppler
This skill does NOT cover:
- Runtime secret injection in Kubernetes (see
engineering/ci-cd-pipeline-builderfor deployment pipeline secrets) - Infrastructure-as-code for provisioning Vault clusters or SSM policies (see
engineering/ci-cd-pipeline-builder) - Application-level encryption at rest or in transit (see
engineering/api-design-reviewerfor API security patterns) - Identity and access management (IAM) role design or SSO/OIDC provider configuration (see
ra-qm-team/compliance skills for access control frameworks)
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
engineering/ci-cd-pipeline-builder | Inject secrets from Vault/SSM/Doppler into CI/CD pipeline stages | Rotation playbook outputs feed pipeline secret-update steps |
engineering/dependency-auditor | Flag dependencies that bundle or require hardcoded credentials | Dependency audit findings trigger secret leak scans on affected repos |
engineering/skill-security-auditor | Validate that no skill packages ship embedded secrets or credentials | Security audit references this skill's regex patterns for detection |
engineering/codebase-onboarding | Include .env.example setup and secret-manager access in onboarding checklists | Onboarding workflow consumes the .env hierarchy and validation script |
engineering/observability-designer | Monitor authentication failures post-rotation; alert on anomalous secret access | Post-rotation verification metrics flow into observability dashboards |
ra-qm-team/soc2-compliance-auditor | Demonstrate secret management controls for SOC 2 CC6.1 and CC6.6 criteria | Rotation audit logs and access policies serve as SOC 2 evidence artifacts |
#!/usr/bin/env python3
"""Compare env configs across environments to detect drift.
Loads multiple .env files (e.g., .env.development, .env.staging, .env.production)
and reports:
- Variables present in some environments but missing in others
- Value differences for shared variables (with secret redaction)
- Structural inconsistencies (ordering, section grouping)
- Drift percentage between each pair of environments
Usage:
python env_sync_checker.py .env.dev .env.staging .env.prod
python env_sync_checker.py .env.* --baseline .env.example --json
python env_sync_checker.py envs/ --show-values --json
"""
import argparse
import glob
import json
import os
import re
import sys
from pathlib import Path
# Variable names that hold secrets — values will be redacted in output
SECRET_KEY_PATTERNS = [
re.compile(r".*SECRET.*", re.IGNORECASE),
re.compile(r".*PASSWORD.*", re.IGNORECASE),
re.compile(r".*TOKEN.*", re.IGNORECASE),
re.compile(r".*API_KEY.*", re.IGNORECASE),
re.compile(r".*PRIVATE_KEY.*", re.IGNORECASE),
re.compile(r".*ACCESS_KEY.*", re.IGNORECASE),
re.compile(r".*CREDENTIAL.*", re.IGNORECASE),
re.compile(r".*_DSN$", re.IGNORECASE),
re.compile(r"^DATABASE_URL$", re.IGNORECASE),
]
def is_secret_key(key: str) -> bool:
"""Check if a variable name likely holds a secret."""
return any(p.match(key) for p in SECRET_KEY_PATTERNS)
def redact_value(key: str, value: str, show_values: bool = False) -> str:
"""Redact secret values unless --show-values is set."""
if show_values:
return value
if is_secret_key(key) and value:
if len(value) <= 4:
return "****"
return value[:2] + "*" * (len(value) - 4) + value[-2:]
return value
def parse_env_file(filepath: str) -> dict:
"""Parse a .env file into {KEY: value} dict.
Handles comments, blank lines, quoted values, and inline comments.
"""
variables = {}
path = Path(filepath)
if not path.exists():
return variables
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if not key or key.startswith("#"):
continue
value = value.strip()
if value and value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end != -1:
value = value[1:end]
else:
comment_match = re.search(r"\s+#\s", value)
if comment_match:
value = value[: comment_match.start()]
value = value.strip()
variables[key] = value
return variables
def resolve_env_files(paths: list[str]) -> list[tuple[str, str]]:
"""Resolve input paths to (label, filepath) pairs.
If a path is a directory, find all .env* files inside it.
Supports glob patterns.
"""
results = []
for path_arg in paths:
p = Path(path_arg)
if p.is_dir():
# Find all .env* files in the directory
for child in sorted(p.iterdir()):
if child.is_file() and child.name.startswith(".env"):
label = child.name
results.append((label, str(child)))
elif "*" in path_arg or "?" in path_arg:
for match in sorted(glob.glob(path_arg)):
mp = Path(match)
if mp.is_file():
results.append((mp.name, str(mp)))
elif p.is_file():
results.append((p.name, str(p)))
else:
print(f"Warning: Path not found, skipping: {path_arg}", file=sys.stderr)
return results
def compute_drift(env_a: dict, env_b: dict) -> dict:
"""Compute drift metrics between two environments."""
keys_a = set(env_a.keys())
keys_b = set(env_b.keys())
all_keys = keys_a | keys_b
common_keys = keys_a & keys_b
only_a = sorted(keys_a - keys_b)
only_b = sorted(keys_b - keys_a)
# Value differences among shared keys
value_diffs = []
for key in sorted(common_keys):
if env_a[key] != env_b[key]:
value_diffs.append(key)
total = len(all_keys)
drift_keys = len(only_a) + len(only_b) + len(value_diffs)
drift_pct = round((drift_keys / total * 100), 1) if total > 0 else 0.0
return {
"only_first": only_a,
"only_second": only_b,
"value_differences": value_diffs,
"shared_count": len(common_keys),
"total_unique_keys": total,
"drift_count": drift_keys,
"drift_percentage": drift_pct,
}
def check_sync(
env_files: list[tuple[str, str]],
baseline_path: str | None = None,
show_values: bool = False,
) -> dict:
"""Run full sync check across all environments."""
# Parse all env files
environments = {}
for label, filepath in env_files:
environments[label] = {
"path": filepath,
"vars": parse_env_file(filepath),
}
if len(environments) < 2:
return {
"error": "Need at least 2 environment files to compare",
"environments_found": len(environments),
"passed": False,
}
# Parse baseline if provided
baseline_vars = None
if baseline_path:
baseline_vars = parse_env_file(baseline_path)
# Collect all keys across all environments
all_keys = set()
for env_data in environments.values():
all_keys.update(env_data["vars"].keys())
all_keys = sorted(all_keys)
# Build the key presence matrix
key_matrix = {}
for key in all_keys:
key_matrix[key] = {}
for label, env_data in environments.items():
if key in env_data["vars"]:
key_matrix[key][label] = redact_value(key, env_data["vars"][key], show_values)
else:
key_matrix[key][label] = None # missing
# Find keys not present in all environments
env_labels = list(environments.keys())
missing_keys = {} # key -> list of envs where it's missing
for key in all_keys:
missing_in = [label for label in env_labels if key_matrix[key][label] is None]
if missing_in and len(missing_in) < len(env_labels):
missing_keys[key] = {
"present_in": [l for l in env_labels if key_matrix[key][l] is not None],
"missing_in": missing_in,
}
# Baseline comparison
baseline_issues = []
if baseline_vars is not None:
baseline_keys = set(baseline_vars.keys())
for label, env_data in environments.items():
env_keys = set(env_data["vars"].keys())
missing_from_baseline = sorted(baseline_keys - env_keys)
extra_beyond_baseline = sorted(env_keys - baseline_keys)
if missing_from_baseline or extra_beyond_baseline:
baseline_issues.append({
"environment": label,
"missing_from_baseline": missing_from_baseline,
"extra_beyond_baseline": extra_beyond_baseline,
})
# Pairwise drift
pairwise_drift = []
for i in range(len(env_labels)):
for j in range(i + 1, len(env_labels)):
label_a = env_labels[i]
label_b = env_labels[j]
drift = compute_drift(
environments[label_a]["vars"],
environments[label_b]["vars"],
)
pairwise_drift.append({
"pair": [label_a, label_b],
**drift,
})
# Overall pass/fail: drift > 5% on any pair is a fail (per SKILL.md success criteria)
max_drift = max((d["drift_percentage"] for d in pairwise_drift), default=0.0)
passed = max_drift <= 5.0 and not baseline_issues
return {
"environments": {label: {"path": d["path"], "var_count": len(d["vars"])}
for label, d in environments.items()},
"total_unique_keys": len(all_keys),
"missing_keys": missing_keys,
"pairwise_drift": pairwise_drift,
"max_drift_percentage": max_drift,
"baseline_file": baseline_path,
"baseline_issues": baseline_issues,
"passed": passed,
}
def print_human(results: dict) -> None:
"""Pretty-print sync check results."""
if "error" in results:
print(f"Error: {results['error']}")
return
print("Env Sync Checker")
print("=" * 60)
# Environment summary
for label, info in results["environments"].items():
print(f" {label:<30} {info['var_count']} vars ({info['path']})")
print(f" Total unique keys: {results['total_unique_keys']}")
print()
# Missing keys
missing = results["missing_keys"]
if missing:
print(f"MISSING KEYS ({len(missing)} keys not present everywhere):")
for key, info in missing.items():
present = ", ".join(info["present_in"])
absent = ", ".join(info["missing_in"])
print(f" {key}")
print(f" present: {present}")
print(f" missing: {absent}")
print()
# Pairwise drift
print("PAIRWISE DRIFT:")
for drift in results["pairwise_drift"]:
pair = " <-> ".join(drift["pair"])
pct = drift["drift_percentage"]
status = "OK" if pct <= 5.0 else "DRIFT"
print(f" {pair}: {pct}% drift ({drift['drift_count']}/{drift['total_unique_keys']} keys) [{status}]")
if drift["only_first"]:
print(f" Only in {drift['pair'][0]}: {', '.join(drift['only_first'])}")
if drift["only_second"]:
print(f" Only in {drift['pair'][1]}: {', '.join(drift['only_second'])}")
if drift["value_differences"]:
print(f" Value differs: {', '.join(drift['value_differences'])}")
print()
# Baseline issues
if results["baseline_file"]:
if results["baseline_issues"]:
print(f"BASELINE COMPARISON (vs {results['baseline_file']}):")
for issue in results["baseline_issues"]:
print(f" {issue['environment']}:")
if issue["missing_from_baseline"]:
print(f" Missing from baseline: {', '.join(issue['missing_from_baseline'])}")
if issue["extra_beyond_baseline"]:
print(f" Extra beyond baseline: {', '.join(issue['extra_beyond_baseline'])}")
print()
else:
print(f"Baseline ({results['baseline_file']}): All environments match.")
print()
# Overall result
status = "PASSED" if results["passed"] else "FAILED"
print(f"Result: {status} (max drift: {results['max_drift_percentage']}%)")
if not results["passed"]:
print(" Target: <= 5% drift between environments")
def main() -> int:
parser = argparse.ArgumentParser(
description="Compare env configs across environments (dev/staging/prod) to detect drift. "
"Reports missing keys, value differences, and drift percentage.",
epilog="Examples:\n"
" %(prog)s .env.dev .env.staging .env.prod\n"
" %(prog)s .env.* --baseline .env.example --json\n"
" %(prog)s envs/ --show-values\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("paths", nargs="+",
help="Env files, directories, or glob patterns to compare")
parser.add_argument("--baseline", metavar="FILE",
help="Baseline file (e.g., .env.example) to compare all envs against")
parser.add_argument("--show-values", action="store_true",
help="Show actual values instead of redacting secrets")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
args = parser.parse_args()
env_files = resolve_env_files(args.paths)
if not env_files:
print("Error: No env files found from the provided paths.", file=sys.stderr)
return 2
if args.baseline and not Path(args.baseline).exists():
print(f"Error: Baseline file not found: {args.baseline}", file=sys.stderr)
return 2
results = check_sync(env_files, baseline_path=args.baseline, show_values=args.show_values)
if args.json_output:
print(json.dumps(results, indent=2))
else:
print_human(results)
if "error" in results:
return 2
return 0 if results["passed"] else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Validate .env files against .env.example — detect missing, extra, and suspicious vars.
Compares a target .env file against a reference .env.example to find:
- Missing variables (in example but not in target)
- Extra variables (in target but not in example)
- Empty required variables
- Secrets accidentally left in .env.example
- Variables with placeholder/default values that should be customized
Usage:
python env_validator.py .env.example .env
python env_validator.py .env.example .env --strict --json
python env_validator.py .env.example .env.staging --check-secrets
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
# Patterns that suggest a value is a real secret (not a placeholder)
SECRET_VALUE_PATTERNS = [
(r"^AKIA[0-9A-Z]{16}$", "AWS Access Key ID"),
(r"^sk_(live|test)_[0-9a-zA-Z]{24,}$", "Stripe Secret Key"),
(r"^ghp_[0-9a-zA-Z]{36}$", "GitHub Personal Access Token"),
(r"^glpat-[0-9a-zA-Z\-]{20,}$", "GitLab Personal Access Token"),
(r"^SG\.[0-9A-Za-z\-_]{22}\.[0-9A-Za-z\-_]{43}$", "SendGrid API Key"),
(r"^xox[bpras]-[0-9a-zA-Z\-]{10,}$", "Slack Token"),
(r"^whsec_[0-9a-zA-Z]{32,}$", "Stripe Webhook Secret"),
(r"^-----BEGIN (RSA |EC )?PRIVATE KEY-----", "Private Key"),
(r"^eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.", "JWT Token"),
]
# Variable names that typically hold secrets
SECRET_KEY_PATTERNS = [
r".*SECRET.*",
r".*PASSWORD.*",
r".*TOKEN.*",
r".*API_KEY.*",
r".*PRIVATE_KEY.*",
r".*ACCESS_KEY.*",
r".*CREDENTIAL.*",
r".*AUTH.*KEY.*",
]
# Placeholder values that indicate a var needs customization
PLACEHOLDER_PATTERNS = [
r"^(changeme|CHANGEME|replace_me|REPLACE_ME|todo|TODO|xxx|XXX|your[_-].*here)$",
r"^<.*>$",
r"^\{\{.*\}\}$",
r"^\$\{.*\}$",
]
def parse_env_file(filepath: str) -> dict:
"""Parse a .env file into a dict of {KEY: value}.
Handles comments, blank lines, quoted values, and inline comments.
Returns dict with keys mapped to their string values (empty string if unset).
"""
variables = {}
path = Path(filepath)
if not path.exists():
return variables
with open(path, "r", encoding="utf-8") as f:
for line_num, raw_line in enumerate(f, start=1):
line = raw_line.strip()
# Skip blanks and comments
if not line or line.startswith("#"):
continue
# Must contain '='
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if not key or key.startswith("#"):
continue
# Strip inline comments (not inside quotes)
value = value.strip()
if value and value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end != -1:
value = value[1:end]
else:
# Remove inline comment
comment_match = re.search(r"\s+#\s", value)
if comment_match:
value = value[: comment_match.start()]
value = value.strip()
variables[key] = value
return variables
def is_secret_key(key: str) -> bool:
"""Check if a variable name looks like it holds a secret."""
upper = key.upper()
return any(re.match(p, upper) for p in SECRET_KEY_PATTERNS)
def detect_real_secret_value(value: str) -> str | None:
"""Return description if value matches a known secret format, else None."""
for pattern, desc in SECRET_VALUE_PATTERNS:
if re.search(pattern, value):
return desc
return None
def is_placeholder(value: str) -> bool:
"""Check if a value looks like a placeholder that needs customization."""
return any(re.match(p, value, re.IGNORECASE) for p in PLACEHOLDER_PATTERNS)
def validate(
reference_path: str,
target_path: str,
strict: bool = False,
check_secrets: bool = False,
) -> dict:
"""Run all validation checks. Returns a results dict."""
ref_vars = parse_env_file(reference_path)
target_vars = parse_env_file(target_path)
ref_keys = set(ref_vars.keys())
target_keys = set(target_vars.keys())
missing = sorted(ref_keys - target_keys)
extra = sorted(target_keys - ref_keys)
# Empty required vars (secret-looking keys with empty values)
empty_required = []
for key in sorted(ref_keys & target_keys):
if is_secret_key(key) and not target_vars.get(key, ""):
empty_required.append(key)
# Placeholders that need customization
placeholders = []
for key in sorted(target_keys):
val = target_vars[key]
if val and is_placeholder(val):
placeholders.append(key)
# Secrets leaked into reference file (e.g., .env.example with real values)
leaked_in_reference = []
if check_secrets:
for key, val in sorted(ref_vars.items()):
if not val:
continue
secret_type = detect_real_secret_value(val)
if secret_type:
leaked_in_reference.append({"key": key, "type": secret_type})
elif is_secret_key(key) and len(val) > 8 and not is_placeholder(val):
# A secret-named key with a non-trivial, non-placeholder value
leaked_in_reference.append({"key": key, "type": "potential secret value"})
# Build issues list
issues = []
for key in missing:
issues.append({"severity": "error", "type": "missing", "key": key,
"message": f"Variable '{key}' is in reference but missing from target"})
for key in extra:
sev = "error" if strict else "warning"
issues.append({"severity": sev, "type": "extra", "key": key,
"message": f"Variable '{key}' is in target but not in reference"})
for key in empty_required:
issues.append({"severity": "error", "type": "empty_required", "key": key,
"message": f"Secret variable '{key}' is present but empty"})
for key in placeholders:
issues.append({"severity": "warning", "type": "placeholder", "key": key,
"message": f"Variable '{key}' still has a placeholder value"})
for item in leaked_in_reference:
issues.append({"severity": "error", "type": "leaked_secret", "key": item["key"],
"message": f"Reference file contains a {item['type']} in '{item['key']}'"})
errors = [i for i in issues if i["severity"] == "error"]
warnings = [i for i in issues if i["severity"] == "warning"]
return {
"reference_file": reference_path,
"target_file": target_path,
"reference_count": len(ref_keys),
"target_count": len(target_keys),
"missing_count": len(missing),
"extra_count": len(extra),
"error_count": len(errors),
"warning_count": len(warnings),
"passed": len(errors) == 0,
"issues": issues,
}
def print_human(results: dict) -> None:
"""Pretty-print validation results."""
ref = results["reference_file"]
tgt = results["target_file"]
print(f"Env Validator: {ref} -> {tgt}")
print(f" Reference vars: {results['reference_count']}")
print(f" Target vars: {results['target_count']}")
print()
if not results["issues"]:
print(" All checks passed. No issues found.")
return
errors = [i for i in results["issues"] if i["severity"] == "error"]
warnings = [i for i in results["issues"] if i["severity"] == "warning"]
if errors:
print(f" ERRORS ({len(errors)}):")
for issue in errors:
print(f" [{issue['type'].upper()}] {issue['message']}")
print()
if warnings:
print(f" WARNINGS ({len(warnings)}):")
for issue in warnings:
print(f" [{issue['type'].upper()}] {issue['message']}")
print()
status = "FAILED" if not results["passed"] else "PASSED (with warnings)"
print(f" Result: {status}")
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate .env files against .env.example. "
"Detects missing vars, extra vars, empty secrets, and leaked credentials.",
epilog="Examples:\n"
" %(prog)s .env.example .env\n"
" %(prog)s .env.example .env --strict --json\n"
" %(prog)s .env.example .env.staging --check-secrets\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("reference", help="Reference file (e.g., .env.example)")
parser.add_argument("target", help="Target file to validate (e.g., .env)")
parser.add_argument("--strict", action="store_true",
help="Treat extra variables as errors instead of warnings")
parser.add_argument("--check-secrets", action="store_true",
help="Check reference file for accidentally included real secrets")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
args = parser.parse_args()
if not Path(args.reference).exists():
print(f"Error: Reference file not found: {args.reference}", file=sys.stderr)
return 2
if not Path(args.target).exists():
print(f"Error: Target file not found: {args.target}", file=sys.stderr)
return 2
results = validate(args.reference, args.target,
strict=args.strict, check_secrets=args.check_secrets)
if args.json_output:
print(json.dumps(results, indent=2))
else:
print_human(results)
return 0 if results["passed"] else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Scan a codebase for hardcoded secrets using pattern matching.
Detects API keys, tokens, passwords, private keys, and other credentials
embedded directly in source files. Uses regex patterns against known secret
formats (AWS, Stripe, GitHub, Slack, etc.) plus generic heuristics for
password assignments and high-entropy strings.
Usage:
python secret_scanner.py /path/to/project
python secret_scanner.py . --include "*.py" "*.ts" --json
python secret_scanner.py src/ --severity high --exclude node_modules .git
"""
import argparse
import fnmatch
import json
import math
import os
import re
import sys
from pathlib import Path
# ── Secret Patterns ──────────────────────────────────────────────────────────
# Each tuple: (compiled_regex, description, severity)
SECRET_PATTERNS = [
# Cloud Provider Keys
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS Access Key ID", "high"),
(re.compile(r"(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])"),
"Possible AWS Secret Access Key (40-char base64)", "low"),
(re.compile(r"AIza[0-9A-Za-z\-_]{35}"), "Google API Key", "high"),
# Payment
(re.compile(r"sk_(live|test)_[0-9a-zA-Z]{24,}"), "Stripe Secret Key", "high"),
(re.compile(r"rk_(live|test)_[0-9a-zA-Z]{24,}"), "Stripe Restricted Key", "high"),
(re.compile(r"whsec_[0-9a-zA-Z]{32,}"), "Stripe Webhook Secret", "high"),
# Version Control
(re.compile(r"ghp_[0-9a-zA-Z]{36}"), "GitHub Personal Access Token", "high"),
(re.compile(r"gho_[0-9a-zA-Z]{36}"), "GitHub OAuth Token", "high"),
(re.compile(r"ghs_[0-9a-zA-Z]{36}"), "GitHub App Token", "high"),
(re.compile(r"github_pat_[0-9a-zA-Z_]{82}"), "GitHub Fine-Grained PAT", "high"),
(re.compile(r"glpat-[0-9a-zA-Z\-]{20,}"), "GitLab Personal Access Token", "high"),
# Communication
(re.compile(r"xox[bpras]-[0-9a-zA-Z\-]{10,}"), "Slack Token", "high"),
(re.compile(r"https://hooks\.slack\.com/services/T[0-9A-Z]+/B[0-9A-Z]+/[0-9a-zA-Z]+"),
"Slack Webhook URL", "high"),
# Email / SaaS
(re.compile(r"SG\.[0-9A-Za-z\-_]{22}\.[0-9A-Za-z\-_]{43}"), "SendGrid API Key", "high"),
(re.compile(r"key-[0-9a-zA-Z]{32}"), "Mailgun API Key", "medium"),
# Private Keys
(re.compile(r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
"Private Key", "high"),
# JWT / Bearer Tokens (long ones are suspicious)
(re.compile(r"eyJ[A-Za-z0-9\-_]{20,}\.eyJ[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_.+/=]{20,}"),
"JSON Web Token", "medium"),
# Generic password assignments
(re.compile(r"""(?:password|passwd|pwd|secret|token|api_key|apikey|auth)\s*[:=]\s*['"][^'"]{8,}['"]""",
re.IGNORECASE),
"Hardcoded password/secret assignment", "medium"),
# Connection strings with embedded credentials
(re.compile(r"(?:mysql|postgres|postgresql|mongodb|redis|amqp)://[^:]+:[^@]+@[^\s'\"]+"),
"Connection string with embedded credentials", "high"),
]
# ── File Filters ─────────────────────────────────────────────────────────────
DEFAULT_EXCLUDE_DIRS = {
".git", "node_modules", "__pycache__", ".venv", "venv", ".tox",
".mypy_cache", ".pytest_cache", "dist", "build", ".next", ".nuxt",
"vendor", "target", ".gradle", ".idea", ".vscode",
}
BINARY_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2",
".ttf", ".eot", ".mp3", ".mp4", ".zip", ".tar", ".gz", ".bz2",
".pdf", ".exe", ".dll", ".so", ".dylib", ".class", ".jar", ".pyc",
".wasm", ".o", ".a", ".lib",
}
# Files that commonly contain example/test secrets (lower severity)
EXAMPLE_FILE_PATTERNS = {"*.example", "*.sample", "*.template", "*.test.*", "*.spec.*"}
MAX_FILE_SIZE = 1_000_000 # 1 MB — skip large files
MAX_LINE_LENGTH = 2000 # skip extremely long lines (minified JS, etc.)
def shannon_entropy(s: str) -> float:
"""Calculate Shannon entropy of a string."""
if not s:
return 0.0
freq = {}
for c in s:
freq[c] = freq.get(c, 0) + 1
length = len(s)
return -sum((count / length) * math.log2(count / length) for count in freq.values())
def is_binary_file(filepath: Path) -> bool:
"""Quick heuristic check for binary files."""
if filepath.suffix.lower() in BINARY_EXTENSIONS:
return True
try:
with open(filepath, "rb") as f:
chunk = f.read(512)
return b"\x00" in chunk
except (OSError, PermissionError):
return True
def should_skip_dir(dirname: str, exclude_dirs: set) -> bool:
"""Check if directory should be skipped."""
return dirname in exclude_dirs or dirname.startswith(".")
def is_example_file(filepath: Path) -> bool:
"""Check if file is an example/template (lower severity)."""
name = filepath.name
return any(fnmatch.fnmatch(name, pat) for pat in EXAMPLE_FILE_PATTERNS)
def scan_file(filepath: Path, severity_filter: str | None = None) -> list[dict]:
"""Scan a single file for secrets. Returns list of findings."""
findings = []
severity_rank = {"low": 0, "medium": 1, "high": 2}
min_rank = severity_rank.get(severity_filter, 0) if severity_filter else 0
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, start=1):
if len(line) > MAX_LINE_LENGTH:
continue
stripped = line.strip()
# Skip comments
if stripped.startswith(("#", "//", "/*", "*", "<!--")):
continue
for pattern, description, severity in SECRET_PATTERNS:
if severity_rank.get(severity, 0) < min_rank:
continue
match = pattern.search(line)
if match:
matched_text = match.group(0)
# Truncate display of the matched secret
if len(matched_text) > 16:
display = matched_text[:8] + "..." + matched_text[-4:]
else:
display = matched_text[:4] + "****"
findings.append({
"file": str(filepath),
"line": line_num,
"severity": severity,
"type": description,
"match_preview": display,
"in_example_file": is_example_file(filepath),
})
except (OSError, PermissionError, UnicodeDecodeError):
pass
return findings
def scan_directory(
root: str,
include_patterns: list[str] | None = None,
exclude_dirs: set | None = None,
severity_filter: str | None = None,
) -> list[dict]:
"""Walk a directory tree and scan all eligible files."""
if exclude_dirs is None:
exclude_dirs = DEFAULT_EXCLUDE_DIRS
all_findings = []
root_path = Path(root).resolve()
for dirpath, dirnames, filenames in os.walk(root_path):
# Prune excluded directories
dirnames[:] = [d for d in dirnames if not should_skip_dir(d, exclude_dirs)]
for filename in filenames:
filepath = Path(dirpath) / filename
# Skip binary and oversized files
if is_binary_file(filepath):
continue
try:
if filepath.stat().st_size > MAX_FILE_SIZE:
continue
except OSError:
continue
# Apply include filter
if include_patterns:
if not any(fnmatch.fnmatch(filename, p) for p in include_patterns):
continue
findings = scan_file(filepath, severity_filter)
all_findings.extend(findings)
return all_findings
def print_human(findings: list[dict], root: str) -> None:
"""Pretty-print scan results."""
print(f"Secret Scanner: {root}")
print(f" Total findings: {len(findings)}")
if not findings:
print(" No hardcoded secrets detected.")
return
high = [f for f in findings if f["severity"] == "high"]
medium = [f for f in findings if f["severity"] == "medium"]
low = [f for f in findings if f["severity"] == "low"]
print(f" High: {len(high)} Medium: {len(medium)} Low: {len(low)}")
print()
severity_order = {"high": 0, "medium": 1, "low": 2}
sorted_findings = sorted(findings, key=lambda f: (severity_order.get(f["severity"], 3), f["file"], f["line"]))
# Group by file
current_file = None
for finding in sorted_findings:
if finding["file"] != current_file:
current_file = finding["file"]
# Show relative path if possible
try:
display_path = str(Path(current_file).relative_to(Path(root).resolve()))
except ValueError:
display_path = current_file
print(f" {display_path}:")
sev = finding["severity"].upper()
line = finding["line"]
desc = finding["type"]
preview = finding["match_preview"]
example_tag = " [example file]" if finding["in_example_file"] else ""
print(f" L{line:<5} [{sev:<6}] {desc}: {preview}{example_tag}")
print()
if high:
print(" ACTION REQUIRED: High-severity findings should be rotated immediately.")
print(" Remove secrets from source, use environment variables or a secret manager.")
def build_summary(findings: list[dict], root: str) -> dict:
"""Build a JSON-friendly summary."""
high = [f for f in findings if f["severity"] == "high"]
medium = [f for f in findings if f["severity"] == "medium"]
low = [f for f in findings if f["severity"] == "low"]
unique_files = len(set(f["file"] for f in findings))
return {
"scan_root": str(Path(root).resolve()),
"total_findings": len(findings),
"high_count": len(high),
"medium_count": len(medium),
"low_count": len(low),
"files_affected": unique_files,
"passed": len(high) == 0,
"findings": findings,
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Scan a codebase for hardcoded secrets (API keys, tokens, passwords, "
"private keys) using pattern matching. No external dependencies required.",
epilog="Examples:\n"
" %(prog)s /path/to/project\n"
" %(prog)s . --include '*.py' '*.ts' --json\n"
" %(prog)s src/ --severity high --exclude node_modules .git dist\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("path", help="Directory or file to scan")
parser.add_argument("--include", nargs="+", metavar="PATTERN",
help="Only scan files matching these glob patterns (e.g., '*.py' '*.js')")
parser.add_argument("--exclude", nargs="+", metavar="DIR",
help="Directory names to exclude (added to defaults)")
parser.add_argument("--severity", choices=["low", "medium", "high"],
help="Minimum severity to report")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
args = parser.parse_args()
target = Path(args.path)
if not target.exists():
print(f"Error: Path not found: {args.path}", file=sys.stderr)
return 2
exclude_dirs = set(DEFAULT_EXCLUDE_DIRS)
if args.exclude:
exclude_dirs.update(args.exclude)
if target.is_file():
findings = scan_file(target, args.severity)
else:
findings = scan_directory(str(target), args.include, exclude_dirs, args.severity)
if args.json_output:
summary = build_summary(findings, args.path)
print(json.dumps(summary, indent=2))
else:
print_human(findings, args.path)
# Exit 1 if high-severity findings exist
has_high = any(f["severity"] == "high" for f in findings)
return 1 if has_high else 0
if __name__ == "__main__":
sys.exit(main())
Related skills
FAQ
Which secret managers does it integrate with?
HashiCorp Vault (KV v2 with OIDC), AWS SSM Parameter Store with KMS, 1Password CLI, and Doppler.
Can it catch committed secrets?
Yes. It regex-scans git history and staged files for API keys, tokens, passwords, and private keys, and integrates a pre-commit hook to block secret commits.