Initial commit
This commit is contained in:
453
skills/env-config-validator/scripts/validate_env.py
Normal file
453
skills/env-config-validator/scripts/validate_env.py
Normal file
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Validate environment configuration files.
|
||||
|
||||
Usage:
|
||||
python validate_env.py
|
||||
python validate_env.py --file .env.production
|
||||
python validate_env.py --compare .env.local .env.production
|
||||
python validate_env.py --template .env.example
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Configure stdout for UTF-8 encoding (prevents Windows encoding errors)
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
from typing import Dict, List, Set, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""Represents a validation issue."""
|
||||
severity: str # 'error', 'warning', 'info'
|
||||
category: str # 'security', 'naming', 'missing', 'weak'
|
||||
variable: str
|
||||
message: str
|
||||
suggestion: Optional[str] = None
|
||||
|
||||
|
||||
class EnvValidator:
|
||||
"""Validate environment configuration files."""
|
||||
|
||||
# Required variable categories
|
||||
REQUIRED_CATEGORIES = {
|
||||
'database': ['DATABASE_URL', 'DB_URL', 'POSTGRES_URL', 'MONGODB_URI', 'MYSQL_URL'],
|
||||
'auth': ['JWT_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'SESSION_SECRET'],
|
||||
}
|
||||
|
||||
# Common secrets that should NOT be public
|
||||
SECRET_PATTERNS = [
|
||||
'SECRET', 'PASSWORD', 'KEY', 'TOKEN', 'CREDENTIAL',
|
||||
'DATABASE', 'DB_URL', 'CONNECTION', 'PRIVATE',
|
||||
]
|
||||
|
||||
# Weak or default values
|
||||
WEAK_VALUES = [
|
||||
'secret', 'password', 'test', 'example', 'changeme',
|
||||
'123456', 'admin', 'default', 'your-secret-here',
|
||||
]
|
||||
|
||||
def __init__(self, env_file: str = '.env'):
|
||||
self.env_file = Path(env_file)
|
||||
self.variables: Dict[str, str] = {}
|
||||
self.issues: List[ValidationIssue] = []
|
||||
|
||||
def load_env_file(self) -> bool:
|
||||
"""Load and parse environment file."""
|
||||
if not self.env_file.exists():
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='missing',
|
||||
variable='',
|
||||
message=f'Environment file not found: {self.env_file}',
|
||||
suggestion='Create the file or check the path'
|
||||
))
|
||||
return False
|
||||
|
||||
try:
|
||||
content = self.env_file.read_text(encoding='utf-8')
|
||||
except Exception as e:
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='parsing',
|
||||
variable='',
|
||||
message=f'Failed to read file: {e}',
|
||||
))
|
||||
return False
|
||||
|
||||
# Parse environment variables
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse KEY=VALUE
|
||||
match = re.match(r'^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$', line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
value = match.group(2).strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
elif value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1]
|
||||
|
||||
self.variables[key] = value
|
||||
|
||||
return True
|
||||
|
||||
def validate(self) -> List[ValidationIssue]:
|
||||
"""Run all validation checks."""
|
||||
self.issues = []
|
||||
|
||||
if not self.load_env_file():
|
||||
return self.issues
|
||||
|
||||
# Run validation checks
|
||||
self._check_required_variables()
|
||||
self._check_naming_conventions()
|
||||
self._check_public_secrets()
|
||||
self._check_weak_values()
|
||||
self._check_secret_strength()
|
||||
|
||||
return self.issues
|
||||
|
||||
def _check_required_variables(self):
|
||||
"""Check for required variables."""
|
||||
for category, possible_vars in self.REQUIRED_CATEGORIES.items():
|
||||
found = any(var in self.variables for var in possible_vars)
|
||||
|
||||
if not found:
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='missing',
|
||||
variable=category,
|
||||
message=f'Missing required {category} configuration',
|
||||
suggestion=f'Add one of: {", ".join(possible_vars)}'
|
||||
))
|
||||
|
||||
def _check_naming_conventions(self):
|
||||
"""Check variable naming conventions."""
|
||||
for key in self.variables.keys():
|
||||
# Check for lowercase or mixed case
|
||||
if not key.isupper():
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='naming',
|
||||
variable=key,
|
||||
message=f'Variable should use SCREAMING_SNAKE_CASE',
|
||||
suggestion=f'Rename to: {key.upper()}'
|
||||
))
|
||||
|
||||
# Check for invalid characters
|
||||
if not re.match(r'^[A-Z_][A-Z0-9_]*$', key):
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='naming',
|
||||
variable=key,
|
||||
message='Variable name contains invalid characters',
|
||||
suggestion='Use only uppercase letters, numbers, and underscores'
|
||||
))
|
||||
|
||||
def _check_public_secrets(self):
|
||||
"""Check for secrets in public variables."""
|
||||
for key, value in self.variables.items():
|
||||
# Check if variable is public
|
||||
if not key.startswith('NEXT_PUBLIC_'):
|
||||
continue
|
||||
|
||||
# Check if it looks like a secret
|
||||
for pattern in self.SECRET_PATTERNS:
|
||||
if pattern in key.upper():
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='security',
|
||||
variable=key,
|
||||
message='Secret exposed in public variable',
|
||||
suggestion=f'Remove NEXT_PUBLIC_ prefix to make it private'
|
||||
))
|
||||
break
|
||||
|
||||
# Check for database URLs in public vars
|
||||
if any(db in key.upper() for db in ['DATABASE', 'DB_URL', 'POSTGRES', 'MONGODB', 'MYSQL']):
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='security',
|
||||
variable=key,
|
||||
message='Database credentials exposed in public variable',
|
||||
suggestion='Remove NEXT_PUBLIC_ prefix - database URLs must be private'
|
||||
))
|
||||
|
||||
def _check_weak_values(self):
|
||||
"""Check for weak or default values."""
|
||||
for key, value in self.variables.items():
|
||||
value_lower = value.lower()
|
||||
|
||||
# Check for weak values
|
||||
for weak in self.WEAK_VALUES:
|
||||
if weak in value_lower:
|
||||
# Only error for secrets, warn for others
|
||||
severity = 'error' if any(p in key.upper() for p in self.SECRET_PATTERNS) else 'warning'
|
||||
|
||||
self.issues.append(ValidationIssue(
|
||||
severity=severity,
|
||||
category='weak',
|
||||
variable=key,
|
||||
message=f'Weak or default value detected',
|
||||
suggestion='Use a strong, random value'
|
||||
))
|
||||
break
|
||||
|
||||
# Check for empty values in required vars
|
||||
if not value:
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='missing',
|
||||
variable=key,
|
||||
message='Variable is defined but has no value',
|
||||
suggestion='Provide a value or remove the variable'
|
||||
))
|
||||
|
||||
def _check_secret_strength(self):
|
||||
"""Check strength of secret values."""
|
||||
secret_keys = [
|
||||
'JWT_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET',
|
||||
'SESSION_SECRET', 'ENCRYPTION_KEY', 'SECRET_KEY'
|
||||
]
|
||||
|
||||
for key in secret_keys:
|
||||
if key not in self.variables:
|
||||
continue
|
||||
|
||||
value = self.variables[key]
|
||||
|
||||
# Check minimum length (32 characters recommended)
|
||||
if len(value) < 32:
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='weak',
|
||||
variable=key,
|
||||
message=f'Secret is too short ({len(value)} characters, minimum 32 recommended)',
|
||||
suggestion='Generate a stronger secret with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||
))
|
||||
|
||||
# Check for low entropy (repeating characters, patterns)
|
||||
if self._is_low_entropy(value):
|
||||
self.issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='weak',
|
||||
variable=key,
|
||||
message='Secret appears to have low entropy',
|
||||
suggestion='Use a cryptographically random value'
|
||||
))
|
||||
|
||||
def _is_low_entropy(self, value: str) -> bool:
|
||||
"""Check if a value has low entropy."""
|
||||
if len(value) < 10:
|
||||
return True
|
||||
|
||||
# Check for repeating characters
|
||||
unique_chars = len(set(value))
|
||||
if unique_chars < len(value) / 3:
|
||||
return True
|
||||
|
||||
# Check for sequential patterns
|
||||
if re.search(r'(.)\1{5,}', value): # Same char repeated 5+ times
|
||||
return True
|
||||
|
||||
if re.search(r'(abc|123|xyz|789)', value.lower()): # Sequential patterns
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def compare_with(self, other_env_file: str) -> List[ValidationIssue]:
|
||||
"""Compare with another environment file."""
|
||||
other = EnvValidator(other_env_file)
|
||||
other.load_env_file()
|
||||
|
||||
comparison_issues = []
|
||||
|
||||
# Variables in this file but not in other
|
||||
missing_in_other = set(self.variables.keys()) - set(other.variables.keys())
|
||||
for var in missing_in_other:
|
||||
comparison_issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='missing',
|
||||
variable=var,
|
||||
message=f'Variable exists in {self.env_file} but missing in {other_env_file}',
|
||||
suggestion=f'Add to {other_env_file} if required'
|
||||
))
|
||||
|
||||
# Variables in other but not in this file
|
||||
missing_in_this = set(other.variables.keys()) - set(self.variables.keys())
|
||||
for var in missing_in_this:
|
||||
comparison_issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='missing',
|
||||
variable=var,
|
||||
message=f'Variable exists in {other_env_file} but missing in {self.env_file}',
|
||||
suggestion=f'Add to {self.env_file} if required'
|
||||
))
|
||||
|
||||
# Check for same values (potential copy-paste error)
|
||||
for var in set(self.variables.keys()) & set(other.variables.keys()):
|
||||
# Skip checking NODE_ENV and similar environment-specific vars
|
||||
skip_vars = ['NODE_ENV', 'NEXT_PUBLIC_APP_NAME']
|
||||
if var in skip_vars:
|
||||
continue
|
||||
|
||||
if self.variables[var] == other.variables[var]:
|
||||
# Only warn for secrets or URLs (should differ per environment)
|
||||
if any(p in var.upper() for p in ['SECRET', 'URL', 'KEY', 'TOKEN']):
|
||||
if not var.startswith('NEXT_PUBLIC_'): # Public URLs might be same
|
||||
comparison_issues.append(ValidationIssue(
|
||||
severity='warning',
|
||||
category='security',
|
||||
variable=var,
|
||||
message=f'Same value in both {self.env_file} and {other_env_file}',
|
||||
suggestion='Ensure environment-specific values are different'
|
||||
))
|
||||
|
||||
return comparison_issues
|
||||
|
||||
def validate_against_template(self, template_file: str) -> List[ValidationIssue]:
|
||||
"""Validate against a template file (.env.example)."""
|
||||
template = EnvValidator(template_file)
|
||||
template.load_env_file()
|
||||
|
||||
template_issues = []
|
||||
|
||||
# Check for missing required variables
|
||||
for var in template.variables.keys():
|
||||
if var not in self.variables:
|
||||
template_issues.append(ValidationIssue(
|
||||
severity='error',
|
||||
category='missing',
|
||||
variable=var,
|
||||
message=f'Required variable (from {template_file}) is missing',
|
||||
suggestion=f'Add {var} to {self.env_file}'
|
||||
))
|
||||
|
||||
return template_issues
|
||||
|
||||
|
||||
def format_issues(issues: List[ValidationIssue]) -> str:
|
||||
"""Format issues for display."""
|
||||
if not issues:
|
||||
return "[OK] No issues found!"
|
||||
|
||||
lines = []
|
||||
|
||||
# Group by severity
|
||||
errors = [i for i in issues if i.severity == 'error']
|
||||
warnings = [i for i in issues if i.severity == 'warning']
|
||||
info = [i for i in issues if i.severity == 'info']
|
||||
|
||||
if errors:
|
||||
lines.append("\n=== ERRORS ===\n")
|
||||
for issue in errors:
|
||||
lines.append(f"[X] [{issue.category.upper()}] {issue.variable}")
|
||||
lines.append(f" {issue.message}")
|
||||
if issue.suggestion:
|
||||
lines.append(f" [TIP] {issue.suggestion}")
|
||||
lines.append("")
|
||||
|
||||
if warnings:
|
||||
lines.append("\n=== WARNINGS ===\n")
|
||||
for issue in warnings:
|
||||
lines.append(f"[WARN] [{issue.category.upper()}] {issue.variable}")
|
||||
lines.append(f" {issue.message}")
|
||||
if issue.suggestion:
|
||||
lines.append(f" [TIP] {issue.suggestion}")
|
||||
lines.append("")
|
||||
|
||||
if info:
|
||||
lines.append("\n=== INFO ===\n")
|
||||
for issue in info:
|
||||
lines.append(f"[INFO] [{issue.category.upper()}] {issue.variable}")
|
||||
lines.append(f" {issue.message}")
|
||||
if issue.suggestion:
|
||||
lines.append(f" [TIP] {issue.suggestion}")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append("\n=== SUMMARY ===")
|
||||
lines.append(f"Errors: {len(errors)}")
|
||||
lines.append(f"Warnings: {len(warnings)}")
|
||||
lines.append(f"Info: {len(info)}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Validate environment configuration files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--file',
|
||||
default='.env',
|
||||
help='Environment file to validate (default: .env)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--compare',
|
||||
nargs=2,
|
||||
metavar=('FILE1', 'FILE2'),
|
||||
help='Compare two environment files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--template',
|
||||
help='Validate against a template file (.env.example)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Comparison mode
|
||||
if args.compare:
|
||||
file1, file2 = args.compare
|
||||
print(f"Comparing {file1} and {file2}...\n")
|
||||
|
||||
validator = EnvValidator(file1)
|
||||
validator.load_env_file()
|
||||
|
||||
comparison_issues = validator.compare_with(file2)
|
||||
|
||||
print(format_issues(comparison_issues))
|
||||
return 1 if any(i.severity == 'error' for i in comparison_issues) else 0
|
||||
|
||||
# Template validation mode
|
||||
if args.template:
|
||||
print(f"Validating {args.file} against template {args.template}...\n")
|
||||
|
||||
validator = EnvValidator(args.file)
|
||||
issues = validator.validate()
|
||||
|
||||
template_issues = validator.validate_against_template(args.template)
|
||||
all_issues = issues + template_issues
|
||||
|
||||
print(format_issues(all_issues))
|
||||
return 1 if any(i.severity == 'error' for i in all_issues) else 0
|
||||
|
||||
# Standard validation mode
|
||||
print(f"Validating {args.file}...\n")
|
||||
|
||||
validator = EnvValidator(args.file)
|
||||
issues = validator.validate()
|
||||
|
||||
print(format_issues(issues))
|
||||
|
||||
return 1 if any(i.severity == 'error' for i in issues) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user