Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:15 +08:00
commit 3b04d08a92
7 changed files with 1719 additions and 0 deletions

View 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())