Coverage for fastblocks/mcp/env_manager.py: 0%
257 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Environment variable management system for FastBlocks configuration."""
3import os
4import re
5from dataclasses import dataclass, field
6from pathlib import Path
7from typing import Any
9from .configuration import ConfigurationSchema, EnvironmentVariable
12@dataclass
13class EnvironmentValidationResult:
14 """Result of environment variable validation."""
16 valid: bool = True
17 missing_required: list[str] = field(default_factory=list)
18 invalid_format: list[str] = field(default_factory=list)
19 security_warnings: list[str] = field(default_factory=list)
20 recommendations: list[str] = field(default_factory=list)
23@dataclass
24class EnvironmentTemplate:
25 """Template for environment variable generation."""
27 name: str
28 description: str
29 variables: list[EnvironmentVariable] = field(default_factory=list)
30 example_values: dict[str, str] = field(default_factory=dict)
33class EnvironmentManager:
34 """Comprehensive environment variable management for FastBlocks."""
36 def __init__(self, base_path: Path | None = None):
37 """Initialize environment manager."""
38 self.base_path = base_path or Path.cwd()
39 self.env_dir = self.base_path / ".fastblocks" / "env"
40 self.env_dir.mkdir(parents=True, exist_ok=True)
42 # Common validation patterns
43 self.validation_patterns = {
44 "url": re.compile(
45 r"^https?://[^\s/$.?#].[^\s]*$"
46 ), # REGEX OK: URL validation
47 "email": re.compile(
48 r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
49 ), # REGEX OK: email validation
50 "port": re.compile( # REGEX OK: port number validation
51 r"^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$"
52 ),
53 "uuid": re.compile( # REGEX OK: UUID format validation
54 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
55 ),
56 "secret_key": re.compile(
57 r"^[A-Za-z0-9+/]{32,}$"
58 ), # REGEX OK: secret key format validation
59 "path": re.compile(r"^[/~].*"), # REGEX OK: file path validation
60 "boolean": re.compile(
61 r"^(true|false|1|0|yes|no|on|off)$", re.IGNORECASE
62 ), # REGEX OK: boolean value validation
63 "log_level": re.compile( # REGEX OK: log level validation
64 r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", re.IGNORECASE
65 ),
66 }
68 # Security-sensitive variable patterns
69 self.secret_patterns = [
70 re.compile(
71 r".*secret.*", re.IGNORECASE
72 ), # REGEX OK: detect secret env vars
73 re.compile(
74 r".*password.*", re.IGNORECASE
75 ), # REGEX OK: detect password env vars
76 re.compile(r".*key$", re.IGNORECASE), # REGEX OK: detect key env vars
77 re.compile(r".*token.*", re.IGNORECASE), # REGEX OK: detect token env vars
78 re.compile(
79 r".*api_key.*", re.IGNORECASE
80 ), # REGEX OK: detect API key env vars
81 re.compile(
82 r".*private.*", re.IGNORECASE
83 ), # REGEX OK: detect private env vars
84 re.compile(
85 r".*credential.*", re.IGNORECASE
86 ), # REGEX OK: detect credential env vars
87 ]
89 def _check_required_variable(
90 self,
91 var: EnvironmentVariable,
92 current_env: dict[str, str],
93 result: EnvironmentValidationResult,
94 ) -> None:
95 """Check if required variable is present."""
96 if (
97 var.required
98 and var.name not in current_env
99 and not var.value
100 and not var.default
101 ):
102 result.missing_required.append(var.name)
103 result.valid = False
105 def _validate_variable_pattern(
106 self,
107 var: EnvironmentVariable,
108 actual_value: str,
109 result: EnvironmentValidationResult,
110 ) -> None:
111 """Validate variable against its pattern."""
112 if var.validator_pattern:
113 if not re.match(
114 var.validator_pattern, actual_value
115 ): # REGEX OK: custom validator pattern from config
116 result.invalid_format.append(
117 f"{var.name}: does not match pattern {var.validator_pattern}"
118 )
119 result.valid = False
121 def validate_environment_variables(
122 self,
123 variables: list[EnvironmentVariable],
124 current_env: dict[str, str] | None = None,
125 ) -> EnvironmentValidationResult:
126 """Validate environment variables comprehensively."""
127 result = EnvironmentValidationResult()
128 current_env = current_env or os.environ.copy()
130 for var in variables:
131 # Check if required variables are present
132 self._check_required_variable(var, current_env, result)
134 # Get actual value for validation
135 actual_value = current_env.get(var.name) or var.value or var.default
137 if actual_value:
138 # Validate format if pattern is specified
139 self._validate_variable_pattern(var, actual_value, result)
141 # Check against common patterns
142 self._validate_common_patterns(var.name, actual_value, result)
144 # Security checks
145 self._perform_security_checks(
146 var.name, actual_value, var.secret, result
147 )
149 # Additional recommendations
150 self._generate_recommendations(variables, result)
152 return result
154 def _validate_common_patterns(
155 self, name: str, value: str, result: EnvironmentValidationResult
156 ) -> None:
157 """Validate against common patterns."""
158 name_lower = name.lower()
160 self._validate_format_patterns(name, name_lower, value, result)
161 self._validate_uuid_pattern(name, name_lower, value, result)
162 self._validate_boolean_pattern(name, name_lower, value, result)
163 self._validate_log_level_pattern(name, name_lower, value, result)
165 def _validate_format_patterns(
166 self,
167 name: str,
168 name_lower: str,
169 value: str,
170 result: EnvironmentValidationResult,
171 ) -> None:
172 """Validate URL, email, and port formats."""
173 if "url" in name_lower and not self.validation_patterns["url"].match(value):
174 result.invalid_format.append(f"{name}: invalid URL format")
176 if "email" in name_lower and not self.validation_patterns["email"].match(value):
177 result.invalid_format.append(f"{name}: invalid email format")
179 if "port" in name_lower and not self.validation_patterns["port"].match(value):
180 result.invalid_format.append(f"{name}: invalid port number")
182 def _validate_uuid_pattern(
183 self,
184 name: str,
185 name_lower: str,
186 value: str,
187 result: EnvironmentValidationResult,
188 ) -> None:
189 """Validate UUID format."""
190 if "uuid" in name_lower or "id" in name_lower:
191 if not self.validation_patterns["uuid"].match(value):
192 result.recommendations.append(f"{name}: consider using UUID format")
194 def _validate_boolean_pattern(
195 self,
196 name: str,
197 name_lower: str,
198 value: str,
199 result: EnvironmentValidationResult,
200 ) -> None:
201 """Validate boolean values."""
202 if (
203 "debug" in name_lower
204 or "enable" in name_lower
205 or name_lower.endswith("_flag")
206 ):
207 if not self.validation_patterns["boolean"].match(value):
208 result.invalid_format.append(
209 f"{name}: should be boolean (true/false, 1/0, yes/no)"
210 )
212 def _validate_log_level_pattern(
213 self,
214 name: str,
215 name_lower: str,
216 value: str,
217 result: EnvironmentValidationResult,
218 ) -> None:
219 """Validate log level values."""
220 if "log" in name_lower and "level" in name_lower:
221 if not self.validation_patterns["log_level"].match(value):
222 result.invalid_format.append(f"{name}: invalid log level")
224 def _check_secret_strength(
225 self,
226 name: str,
227 value: str,
228 result: EnvironmentValidationResult,
229 ) -> None:
230 """Check strength of secret values."""
231 if len(value) < 16:
232 result.security_warnings.append(
233 f"{name}: secret appears too short (minimum 16 characters recommended)"
234 )
236 if value.lower() in (
237 "password",
238 "secret",
239 "key",
240 "admin",
241 "test",
242 "development",
243 ):
244 result.security_warnings.append(f"{name}: using common/weak secret value")
246 if re.match(
247 r"^(123|abc|test|dev)", value, re.IGNORECASE
248 ): # REGEX OK: detect weak secrets
249 result.security_warnings.append(
250 f"{name}: secret appears to use predictable pattern"
251 )
253 def _perform_security_checks(
254 self,
255 name: str,
256 value: str,
257 is_marked_secret: bool,
258 result: EnvironmentValidationResult,
259 ) -> None:
260 """Perform security checks on environment variables."""
261 # Check if variable should be marked as secret
262 is_potentially_secret = any(
263 pattern.match(name) for pattern in self.secret_patterns
264 )
266 if is_potentially_secret and not is_marked_secret:
267 result.security_warnings.append(
268 f"{name}: appears to contain sensitive data but not marked as secret"
269 )
271 # Check for weak secrets
272 if is_marked_secret or is_potentially_secret:
273 self._check_secret_strength(name, value, result)
275 # Check for exposed secrets in non-secret variables
276 if not (is_marked_secret or is_potentially_secret):
277 if len(value) > 20 and re.match(
278 r"^[A-Za-z0-9+/=]+$", value
279 ): # REGEX OK: detect potential base64-encoded secrets
280 result.security_warnings.append(
281 f"{name}: value looks like encoded data but not marked as secret"
282 )
284 def _generate_recommendations(
285 self, variables: list[EnvironmentVariable], result: EnvironmentValidationResult
286 ) -> None:
287 """Generate recommendations for environment variable configuration."""
288 var_names = {var.name for var in variables}
290 # Check for common missing variables
291 common_vars = {
292 "DATABASE_URL": "database connection",
293 "SECRET_KEY": "application secret",
294 "REDIS_URL": "Redis connection",
295 "LOG_LEVEL": "logging configuration",
296 "DEBUG": "debug mode flag",
297 }
299 for var_name, description in common_vars.items():
300 if not any(var_name in name for name in var_names):
301 result.recommendations.append(
302 f"Consider adding {var_name} for {description}"
303 )
305 # Check for naming consistency
306 prefixes = set()
307 for var in variables:
308 if "_" in var.name:
309 prefix = var.name.split("_")[0]
310 prefixes.add(prefix)
312 if len(prefixes) > 3:
313 result.recommendations.append(
314 "Consider using consistent prefixes for related variables"
315 )
317 def _generate_file_header(self, template: str | None) -> list[str]:
318 """Generate .env file header with documentation."""
319 return [
320 "# FastBlocks Environment Configuration",
321 "# Generated by FastBlocks Configuration Manager",
322 f"# Template: {template or 'custom'}",
323 "",
324 "# IMPORTANT: This file contains sensitive information",
325 "# - Do not commit this file to version control",
326 "# - Copy to .env and customize for your environment",
327 "# - Use .env.example for version control",
328 "",
329 ]
331 def _generate_variable_value(
332 self, var: EnvironmentVariable, include_examples: bool
333 ) -> str:
334 """Generate value for an environment variable."""
335 if var.secret and include_examples:
336 if var.value:
337 return "***REDACTED***"
338 return f"<your-{var.name.lower().replace('_', '-')}>"
339 return var.value or var.default or ""
341 def _generate_variable_lines(
342 self, var: EnvironmentVariable, include_docs: bool, include_examples: bool
343 ) -> list[str]:
344 """Generate lines for a single environment variable."""
345 lines = []
347 # Add description
348 if include_docs and var.description:
349 lines.append(f"# {var.description}")
351 # Add requirement indicator
352 if include_docs:
353 requirement = "REQUIRED" if var.required else "OPTIONAL"
354 lines.append(f"# {requirement}")
356 # Add validation info
357 if include_docs and var.validator_pattern:
358 lines.append(f"# Format: {var.validator_pattern}")
360 # Add variable with value
361 value = self._generate_variable_value(var, include_examples)
362 lines.extend((f"{var.name}={value}", ""))
364 return lines
366 def generate_environment_file(
367 self,
368 variables: list[EnvironmentVariable],
369 output_path: Path | None = None,
370 template: str | None = None,
371 include_examples: bool = True,
372 include_docs: bool = True,
373 ) -> Path:
374 """Generate comprehensive .env file."""
375 if not output_path:
376 output_path = self.base_path / ".env"
378 lines = []
380 if include_docs:
381 lines.extend(self._generate_file_header(template))
383 # Group variables by prefix
384 grouped_vars = self._group_variables_by_prefix(variables)
386 for prefix, prefix_vars in grouped_vars.items():
387 if include_docs:
388 lines.extend((f"# {prefix.upper()} Configuration", ""))
390 for var in prefix_vars:
391 lines.extend(
392 self._generate_variable_lines(var, include_docs, include_examples)
393 )
395 # Write file
396 with output_path.open("w") as f:
397 f.write("\n".join(lines))
399 return output_path
401 def generate_environment_example(
402 self, variables: list[EnvironmentVariable], output_path: Path | None = None
403 ) -> Path:
404 """Generate .env.example file for version control."""
405 if not output_path:
406 output_path = self.base_path / ".env.example"
408 # Create example with placeholder values
409 example_variables = []
410 for var in variables:
411 example_var = EnvironmentVariable(
412 name=var.name,
413 value=self._generate_example_value(var),
414 required=var.required,
415 description=var.description,
416 secret=var.secret,
417 default=var.default,
418 validator_pattern=var.validator_pattern,
419 )
420 example_variables.append(example_var)
422 return self.generate_environment_file(
423 example_variables, output_path, include_examples=True, include_docs=True
424 )
426 def _generate_example_value(self, var: EnvironmentVariable) -> str:
427 """Generate example value for environment variable."""
428 name_lower = var.name.lower()
430 if var.secret:
431 return f"your-{var.name.lower().replace('_', '-')}-here"
433 if "url" in name_lower:
434 if "database" in name_lower:
435 return "postgresql://user:password@localhost:5432/dbname"
436 elif "redis" in name_lower:
437 return "redis://localhost:6379/0"
439 return "https://example.com"
441 if "port" in name_lower:
442 return "8000"
444 if "email" in name_lower:
445 return "admin@example.com"
447 if "debug" in name_lower or name_lower.endswith("_flag"):
448 return "false"
450 if "log" in name_lower and "level" in name_lower:
451 return "INFO"
453 if "path" in name_lower:
454 return "/path/to/directory"
456 return f"example-{var.name.lower().replace('_', '-')}"
458 def _group_variables_by_prefix(
459 self, variables: list[EnvironmentVariable]
460 ) -> dict[str, list[EnvironmentVariable]]:
461 """Group variables by common prefix."""
462 grouped: dict[str, list[EnvironmentVariable]] = {}
464 for var in variables:
465 # Determine prefix
466 if "_" in var.name:
467 prefix = var.name.split("_")[0]
468 else:
469 prefix = "general"
471 if prefix not in grouped:
472 grouped[prefix] = []
473 grouped[prefix].append(var)
475 # Sort variables within each group
476 for prefix in grouped:
477 grouped[prefix].sort(key=lambda v: (not v.required, v.name))
479 return grouped
481 def _parse_env_line(self, line: str) -> tuple[str, str] | None:
482 """Parse a single environment variable line.
484 Args:
485 line: Line to parse
487 Returns:
488 Tuple of (key, value) or None if line should be skipped
489 """
490 line = line.strip()
492 # Skip comments and empty lines
493 if not line or line.startswith("#"):
494 return None
496 # Parse variable assignment
497 if "=" not in line:
498 return None
500 key, value = line.split("=", 1)
501 key = key.strip()
502 value = value.strip()
504 # Remove quotes if present
505 if (value.startswith('"') and value.endswith('"')) or (
506 value.startswith("'") and value.endswith("'")
507 ):
508 value = value[1:-1]
510 return key, value
512 def load_environment_from_file(self, env_file: Path) -> dict[str, str]:
513 """Load environment variables from .env file."""
514 env_vars: dict[str, str] = {}
516 if not env_file.exists():
517 return env_vars
519 with env_file.open() as f:
520 for line in f:
521 parsed = self._parse_env_line(line)
522 if parsed:
523 key, value = parsed
524 env_vars[key] = value
526 return env_vars
528 def sync_environment_variables(
529 self, variables: list[EnvironmentVariable], env_file: Path | None = None
530 ) -> dict[str, Any]:
531 """Sync environment variables with .env file."""
532 env_file = env_file or (self.base_path / ".env")
534 # Load existing environment
535 existing_env = (
536 self.load_environment_from_file(env_file) if env_file.exists() else {}
537 )
539 # Update variables with values from file
540 updated_count = 0
541 new_count = 0
543 for var in variables:
544 if var.name in existing_env:
545 if var.value != existing_env[var.name]:
546 var.value = existing_env[var.name]
547 updated_count += 1
548 else:
549 new_count += 1
551 return {
552 "updated": updated_count,
553 "new": new_count,
554 "total": len(variables),
555 "file_exists": env_file.exists() if env_file else False,
556 }
558 def generate_environment_templates(self) -> dict[str, EnvironmentTemplate]:
559 """Generate standard environment templates."""
560 templates = {}
562 # Development template
563 dev_vars = [
564 EnvironmentVariable("DEBUG", "true", False, "Enable debug mode"),
565 EnvironmentVariable("LOG_LEVEL", "DEBUG", False, "Logging level"),
566 EnvironmentVariable(
567 "SECRET_KEY", None, True, "Application secret key", True
568 ),
569 EnvironmentVariable(
570 "DATABASE_URL", "sqlite:///./dev.db", False, "Database connection"
571 ),
572 EnvironmentVariable(
573 "REDIS_URL", "redis://localhost:6379/0", False, "Redis connection"
574 ),
575 ]
576 templates["development"] = EnvironmentTemplate(
577 "development", "Development environment configuration", dev_vars
578 )
580 # Production template
581 prod_vars = [
582 EnvironmentVariable("DEBUG", "false", True, "Debug mode (should be false)"),
583 EnvironmentVariable("LOG_LEVEL", "WARNING", True, "Logging level"),
584 EnvironmentVariable(
585 "SECRET_KEY", None, True, "Application secret key", True
586 ),
587 EnvironmentVariable(
588 "DATABASE_URL", None, True, "Database connection", True
589 ),
590 EnvironmentVariable("REDIS_URL", None, False, "Redis connection"),
591 EnvironmentVariable("ALLOWED_HOSTS", "*", True, "Allowed hosts"),
592 EnvironmentVariable("HTTPS_ONLY", "true", True, "Force HTTPS"),
593 ]
594 templates["production"] = EnvironmentTemplate(
595 "production", "Production environment configuration", prod_vars
596 )
598 # Testing template
599 test_vars = [
600 EnvironmentVariable("DEBUG", "true", True, "Enable debug mode"),
601 EnvironmentVariable("LOG_LEVEL", "DEBUG", False, "Logging level"),
602 EnvironmentVariable(
603 "SECRET_KEY",
604 "test-secret-key-do-not-use-in-production",
605 True,
606 "Test secret key",
607 ),
608 EnvironmentVariable(
609 "DATABASE_URL", "sqlite:///:memory:", True, "In-memory test database"
610 ),
611 EnvironmentVariable("TESTING", "true", True, "Testing mode flag"),
612 ]
613 templates["testing"] = EnvironmentTemplate(
614 "testing", "Testing environment configuration", test_vars
615 )
617 return templates
619 def extract_variables_from_configuration(
620 self, config: ConfigurationSchema
621 ) -> list[EnvironmentVariable]:
622 """Extract all environment variables from configuration."""
623 all_variables = []
625 # Add global environment variables
626 all_variables.extend(config.global_environment)
628 # Add adapter environment variables
629 for adapter_config in config.adapters.values():
630 if adapter_config.enabled:
631 all_variables.extend(adapter_config.environment_variables)
633 # Remove duplicates (by name)
634 seen_names = set()
635 unique_variables = []
636 for var in all_variables:
637 if var.name not in seen_names:
638 unique_variables.append(var)
639 seen_names.add(var.name)
641 return unique_variables
643 def audit_environment_security(
644 self, variables: list[EnvironmentVariable]
645 ) -> dict[str, list[str]]:
646 """Perform security audit of environment variables."""
647 audit_results: dict[str, Any] = {
648 "critical": [],
649 "high": [],
650 "medium": [],
651 "low": [],
652 "info": [],
653 }
655 for var in variables:
656 self._audit_secret_marking(var, audit_results)
657 self._audit_secret_strength(var, audit_results)
658 self._audit_required_values(var, audit_results)
659 self._audit_format_validation(var, audit_results)
660 self._audit_best_practices(var, audit_results)
662 return audit_results
664 def _audit_secret_marking(
665 self, var: EnvironmentVariable, audit_results: dict[str, list[str]]
666 ) -> None:
667 """Audit if secrets are properly marked."""
668 is_secret_name = any(
669 pattern.match(var.name) for pattern in self.secret_patterns
670 )
671 if is_secret_name and not var.secret:
672 audit_results["critical"].append(
673 f"{var.name}: Contains sensitive data but not marked as secret"
674 )
676 def _audit_secret_strength(
677 self, var: EnvironmentVariable, audit_results: dict[str, list[str]]
678 ) -> None:
679 """Audit secret value strength."""
680 if var.secret and var.value:
681 if len(var.value) < 16:
682 audit_results["high"].append(
683 f"{var.name}: Secret is too short (< 16 characters)"
684 )
685 if var.value.lower() in ("password", "secret", "admin", "test"):
686 audit_results["high"].append(
687 f"{var.name}: Using weak/common secret value"
688 )
690 def _audit_required_values(
691 self, var: EnvironmentVariable, audit_results: dict[str, list[str]]
692 ) -> None:
693 """Audit if required variables have values."""
694 if var.required and not var.value and not var.default:
695 audit_results["medium"].append(
696 f"{var.name}: Required variable has no value or default"
697 )
699 def _audit_format_validation(
700 self, var: EnvironmentVariable, audit_results: dict[str, list[str]]
701 ) -> None:
702 """Audit format validation patterns."""
703 if var.validator_pattern and var.value:
704 if not re.match(
705 var.validator_pattern, var.value
706 ): # REGEX OK: custom validator pattern from config
707 audit_results["low"].append(
708 f"{var.name}: Value doesn't match expected pattern"
709 )
711 def _audit_best_practices(
712 self, var: EnvironmentVariable, audit_results: dict[str, list[str]]
713 ) -> None:
714 """Audit best practice compliance."""
715 if not var.description:
716 audit_results["info"].append(f"{var.name}: Missing description")