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

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/scripts/validate_env.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Mihai Criveti 

6 

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. 

10 

11Usage: 

12 python -m mcpgateway.scripts.validate_env [env_file] 

13 

14Examples: 

15 python -m mcpgateway.scripts.validate_env .env.production 

16 python -m mcpgateway.scripts.validate_env # validates .env 

17""" 

18 

19# Standard 

20import logging 

21import re 

22import string 

23import sys 

24from typing import Optional 

25 

26# Third-Party 

27from pydantic import SecretStr, ValidationError 

28 

29# First-Party 

30from mcpgateway.config import Settings 

31 

32 

33def get_security_warnings(settings: Settings) -> list[str]: 

34 """ 

35 Inspect a Settings object for weak/default secrets, misconfigurations, and potential security risks. 

36 

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 

42 

43 Args: 

44 settings (Settings): The application settings to validate. 

45 

46 Returns: 

47 list[str]: List of warning messages. Empty if no warnings are found. 

48 

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 

62 

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 

67 

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 

73 

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 

78 

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 

83 

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 

89 

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 

95 

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 

100 

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 

105 

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 

111 

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] = [] 

119 

120 # --- Port check --- 

121 if not (1 <= settings.port <= 65535): 

122 warnings.append(f"PORT: Out of allowed range (1-65535). Got: {settings.port}") 

123 

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.") 

128 

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)}") 

132 

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") 

136 

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.") 

141 

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)}") 

145 

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") 

149 

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.") 

155 

156 if len(jwt) < 32: 

157 warnings.append(f"JWT_SECRET_KEY: Secret should be at least 32 characters long. Current length: {len(jwt)}") 

158 

159 if len(set(jwt)) < 10: 

160 warnings.append("JWT_SECRET_KEY: Secret has low entropy. Consider using a more random value.") 

161 

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.") 

167 

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)}") 

170 

171 if len(set(auth_secret)) < 10: 

172 warnings.append("AUTH_ENCRYPTION_SECRET: Secret has low entropy. Consider using a more random value.") 

173 

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}") 

180 

181 return warnings 

182 

183 

184def main(env_file: Optional[str] = None, exit_on_warnings: bool = True) -> int: 

185 """ 

186 Validate the application environment configuration. 

187 

188 Loads settings from the given .env file (or system environment) and checks 

189 for security issues and invalid configurations. 

190 

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). 

196 

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. 

200 

201 Returns: 

202 int: 0 if validation passes, 1 if validation fails (in prod or if invalid). 

203 

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 

210 

211 >>> # Test with invalid configuration would return 1 

212 >>> result = 1 if False else 0 

213 >>> result 

214 0 

215 

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 

220 

221 >>> # Test production environment behavior 

222 >>> is_prod = "production".lower() == "production" 

223 >>> is_prod 

224 True 

225 

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) 

232 

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 

238 

239 warnings = get_security_warnings(settings) 

240 is_prod = settings.environment.lower() == "production" 

241 

242 if warnings: 

243 for w in warnings: 

244 print(f"⚠️ {w}") 

245 

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.") 

252 

253 return 0 

254 

255 

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