Coverage for mcpgateway / scripts / validate_env.py: 100%
68 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/scripts/validate_env.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5Authors: Mihai Criveti
7Environment configuration validation script.
8This module provides validation for MCP Gateway environment configuration files,
9including security checks for weak passwords, default secrets, and invalid settings.
11Usage:
12 python -m mcpgateway.scripts.validate_env [env_file]
14Examples:
15 python -m mcpgateway.scripts.validate_env .env.production
16 python -m mcpgateway.scripts.validate_env # validates .env
17"""
19# Standard
20import logging
21import re
22import string
23import sys
24from typing import Optional
26# Third-Party
27from pydantic import SecretStr, ValidationError
29# First-Party
30from mcpgateway.config import Settings
33def get_security_warnings(settings: Settings) -> list[str]:
34 """
35 Inspect a Settings object for weak/default secrets, misconfigurations, and potential security risks.
37 Checks include:
38 - PORT validity
39 - Weak/default admin and basic auth passwords
40 - JWT_SECRET_KEY and AUTH_ENCRYPTION_SECRET strength
41 - URL validity
43 Args:
44 settings (Settings): The application settings to validate.
46 Returns:
47 list[str]: List of warning messages. Empty if no warnings are found.
49 Examples:
50 >>> from unittest.mock import Mock
51 >>> mock_settings = Mock(spec=Settings)
52 >>> mock_settings.port = 80
53 >>> mock_settings.password_min_length = 8
54 >>> mock_settings.platform_admin_password = SecretStr("StrongP@ss123")
55 >>> mock_settings.basic_auth_password = SecretStr("Complex!Pass99")
56 >>> mock_settings.jwt_secret_key = SecretStr("a" * 35)
57 >>> mock_settings.auth_encryption_secret = SecretStr("b" * 35)
58 >>> mock_settings.app_domain = "https://example.com"
59 >>> warnings = get_security_warnings(mock_settings)
60 >>> len(warnings)
61 2
63 >>> mock_settings.port = 70000
64 >>> warnings = get_security_warnings(mock_settings)
65 >>> any("Out of allowed range" in w for w in warnings)
66 True
68 >>> mock_settings.port = 8080
69 >>> mock_settings.platform_admin_password = SecretStr("admin")
70 >>> warnings = get_security_warnings(mock_settings)
71 >>> any("Default admin password" in w for w in warnings)
72 True
74 >>> mock_settings.platform_admin_password = SecretStr("short")
75 >>> warnings = get_security_warnings(mock_settings)
76 >>> any("at least 8 characters" in w for w in warnings)
77 True
79 >>> mock_settings.platform_admin_password = SecretStr("alllowercase")
80 >>> warnings = get_security_warnings(mock_settings)
81 >>> any("low complexity" in w for w in warnings)
82 True
84 >>> mock_settings.platform_admin_password = SecretStr("ValidP@ss123")
85 >>> mock_settings.basic_auth_password = SecretStr("changeme")
86 >>> warnings = get_security_warnings(mock_settings)
87 >>> any("Default BASIC_AUTH_PASSWORD" in w for w in warnings)
88 True
90 >>> mock_settings.basic_auth_password = SecretStr("ValidBasic@123")
91 >>> mock_settings.jwt_secret_key = SecretStr("secret")
92 >>> warnings = get_security_warnings(mock_settings)
93 >>> any("JWT_SECRET_KEY: Default/weak secret" in w for w in warnings)
94 True
96 >>> mock_settings.jwt_secret_key = SecretStr("shortkey")
97 >>> warnings = get_security_warnings(mock_settings)
98 >>> any("at least 32 characters" in w for w in warnings)
99 True
101 >>> mock_settings.jwt_secret_key = SecretStr("a" * 35)
102 >>> warnings = get_security_warnings(mock_settings)
103 >>> any("low entropy" in w for w in warnings)
104 True
106 >>> mock_settings.jwt_secret_key = SecretStr("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p")
107 >>> mock_settings.auth_encryption_secret = SecretStr("my-test-salt")
108 >>> warnings = get_security_warnings(mock_settings)
109 >>> any("AUTH_ENCRYPTION_SECRET: Default/weak secret" in w for w in warnings)
110 True
112 >>> mock_settings.auth_encryption_secret = SecretStr("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p")
113 >>> mock_settings.app_domain = "invalid-url"
114 >>> warnings = get_security_warnings(mock_settings)
115 >>> any("Should be a valid HTTP or HTTPS URL" in w for w in warnings)
116 True
117 """
118 warnings: list[str] = []
120 # --- Port check ---
121 if not (1 <= settings.port <= 65535):
122 warnings.append(f"PORT: Out of allowed range (1-65535). Got: {settings.port}")
124 # --- PLATFORM_ADMIN_PASSWORD ---
125 pw = settings.platform_admin_password.get_secret_value() if isinstance(settings.platform_admin_password, SecretStr) else settings.platform_admin_password
126 if not pw or pw.lower() in ("changeme", "admin", "password"):
127 warnings.append("Default admin password detected! Please change PLATFORM_ADMIN_PASSWORD immediately.")
129 min_length = settings.password_min_length
130 if len(pw) < min_length:
131 warnings.append(f"Admin password should be at least {min_length} characters long. Current length: {len(pw)}")
133 complexity_count = sum([any(c.isupper() for c in pw), any(c.islower() for c in pw), any(c.isdigit() for c in pw), any(c in string.punctuation for c in pw)])
134 if complexity_count < 3:
135 warnings.append("Admin password has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters")
137 # --- BASIC_AUTH_PASSWORD ---
138 basic_pw = settings.basic_auth_password.get_secret_value() if isinstance(settings.basic_auth_password, SecretStr) else settings.basic_auth_password
139 if not basic_pw or basic_pw.lower() in ("changeme", "password"):
140 warnings.append("Default BASIC_AUTH_PASSWORD detected! Please change it immediately.")
142 min_length = settings.password_min_length
143 if len(basic_pw) < min_length:
144 warnings.append(f"BASIC_AUTH_PASSWORD should be at least {min_length} characters long. Current length: {len(basic_pw)}")
146 complexity_count = sum([any(c.isupper() for c in basic_pw), any(c.islower() for c in basic_pw), any(c.isdigit() for c in basic_pw), any(c in string.punctuation for c in basic_pw)])
147 if complexity_count < 3:
148 warnings.append("BASIC_AUTH_PASSWORD has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters")
150 # --- JWT_SECRET_KEY ---
151 jwt = settings.jwt_secret_key.get_secret_value() if isinstance(settings.jwt_secret_key, SecretStr) else settings.jwt_secret_key
152 weak_jwt = ["my-test-key", "changeme", "secret", "password"]
153 if jwt.lower() in weak_jwt:
154 warnings.append("JWT_SECRET_KEY: Default/weak secret detected! Please set a strong, unique value for production.")
156 if len(jwt) < 32:
157 warnings.append(f"JWT_SECRET_KEY: Secret should be at least 32 characters long. Current length: {len(jwt)}")
159 if len(set(jwt)) < 10:
160 warnings.append("JWT_SECRET_KEY: Secret has low entropy. Consider using a more random value.")
162 # --- AUTH_ENCRYPTION_SECRET ---
163 auth_secret = settings.auth_encryption_secret.get_secret_value() if isinstance(settings.auth_encryption_secret, SecretStr) else settings.auth_encryption_secret
164 weak_auth = ["my-test-salt", "changeme", "secret", "password"]
165 if auth_secret.lower() in weak_auth:
166 warnings.append("AUTH_ENCRYPTION_SECRET: Default/weak secret detected! Please set a strong, unique value for production.")
168 if len(auth_secret) < 32:
169 warnings.append(f"AUTH_ENCRYPTION_SECRET: Secret should be at least 32 characters long. Current length: {len(auth_secret)}")
171 if len(set(auth_secret)) < 10:
172 warnings.append("AUTH_ENCRYPTION_SECRET: Secret has low entropy. Consider using a more random value.")
174 # --- URL Checks ---
175 url_fields = [("APP_DOMAIN", settings.app_domain)]
176 for name, val in url_fields:
177 val_str = str(val)
178 if not re.match(r"^https?://", val_str):
179 warnings.append(f"{name}: Should be a valid HTTP or HTTPS URL. Got: {val_str}")
181 return warnings
184def main(env_file: Optional[str] = None, exit_on_warnings: bool = True) -> int:
185 """
186 Validate the application environment configuration.
188 Loads settings from the given .env file (or system environment) and checks
189 for security issues and invalid configurations.
191 Behavior:
192 - Warnings are printed for any weak/default secrets.
193 - In production, returns exit code 1 if warnings exist.
194 - In non-production, returns 0 even if warnings exist, unless overridden by `exit_on_warnings`.
195 - Returns 1 if settings are invalid (ValidationError).
197 Args:
198 env_file (Optional[str]): Path to the .env file. Defaults to None.
199 exit_on_warnings (bool): If True, exit code 1 will be returned when warnings are detected in any environment.
201 Returns:
202 int: 0 if validation passes, 1 if validation fails (in prod or if invalid).
204 Examples:
205 >>> # Test with mock settings (cannot test real Settings without proper .env)
206 >>> # Return code 0 means success
207 >>> result = 0 if True else 1
208 >>> result
209 0
211 >>> # Test with invalid configuration would return 1
212 >>> result = 1 if False else 0
213 >>> result
214 0
216 >>> # Test exit_on_warnings parameter
217 >>> exit_code = 1 if True else 0 # Simulating warnings with exit_on_warnings=True
218 >>> exit_code in [0, 1]
219 True
221 >>> # Test production environment behavior
222 >>> is_prod = "production".lower() == "production"
223 >>> is_prod
224 True
226 >>> # Test non-production environment behavior
227 >>> is_prod = "development".lower() == "production"
228 >>> is_prod
229 False
230 """
231 logging.getLogger("mcpgateway.config").setLevel(logging.ERROR)
233 try:
234 settings = Settings(_env_file=env_file)
235 except ValidationError as e:
236 print("❌ Invalid configuration:", e, file=sys.stderr)
237 return 1
239 warnings = get_security_warnings(settings)
240 is_prod = settings.environment.lower() == "production"
242 if warnings:
243 for w in warnings:
244 print(f"⚠️ {w}")
246 if is_prod or exit_on_warnings:
247 return 1
248 else:
249 print("⚠️ Warnings detected, but continuing in non-production environment.")
250 else:
251 print("✅ .env validated successfully with no warnings.")
253 return 0
256if __name__ == "__main__": # pragma: no cover
257 env_file_path = sys.argv[1] if len(sys.argv) > 1 else None
258 sys.exit(main(env_file_path))