fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cp311-win_amd64.pyd +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1656 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +132 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +793 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +0 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +180 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/scenarios/__init__.py +36 -0
  99. confiture/scenarios/compliance.py +586 -0
  100. confiture/scenarios/ecommerce.py +199 -0
  101. confiture/scenarios/financial.py +253 -0
  102. confiture/scenarios/healthcare.py +315 -0
  103. confiture/scenarios/multi_tenant.py +340 -0
  104. confiture/scenarios/saas.py +295 -0
  105. confiture/testing/FRAMEWORK_API.md +722 -0
  106. confiture/testing/__init__.py +38 -0
  107. confiture/testing/fixtures/__init__.py +11 -0
  108. confiture/testing/fixtures/data_validator.py +229 -0
  109. confiture/testing/fixtures/migration_runner.py +167 -0
  110. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  111. confiture/testing/frameworks/__init__.py +10 -0
  112. confiture/testing/frameworks/mutation.py +587 -0
  113. confiture/testing/frameworks/performance.py +479 -0
  114. confiture/testing/utils/__init__.py +0 -0
  115. fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
  116. fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
  117. fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
  118. fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
  119. fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,201 @@
1
+ """Secure logging utilities.
2
+
3
+ This module provides logging utilities that automatically redact sensitive
4
+ information from log messages, preventing accidental credential exposure.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from typing import Any
10
+
11
+ from confiture.core.security.validation import sanitize_log_message
12
+
13
+
14
+ class SecureFormatter(logging.Formatter):
15
+ """Logging formatter that automatically redacts sensitive data.
16
+
17
+ This formatter extends the standard logging formatter to automatically
18
+ redact passwords, tokens, API keys, and other sensitive information
19
+ from log messages before they are emitted.
20
+
21
+ Example:
22
+ >>> handler = logging.StreamHandler()
23
+ >>> handler.setFormatter(SecureFormatter())
24
+ >>> logger = logging.getLogger("myapp")
25
+ >>> logger.addHandler(handler)
26
+ >>> logger.info("Connecting to postgresql://user:secret@host/db")
27
+ # Output: Connecting to postgresql://***@host/db
28
+ """
29
+
30
+ # Additional patterns specific to formatting (beyond validation module)
31
+ EXTRA_PATTERNS = [
32
+ (re.compile(r"AWS_SECRET_ACCESS_KEY=\S+", re.IGNORECASE), "AWS_SECRET_ACCESS_KEY=***"),
33
+ (re.compile(r"AWS_ACCESS_KEY_ID=\S+", re.IGNORECASE), "AWS_ACCESS_KEY_ID=***"),
34
+ (re.compile(r"GOOGLE_APPLICATION_CREDENTIALS=\S+"), "GOOGLE_APPLICATION_CREDENTIALS=***"),
35
+ (re.compile(r'"password"\s*:\s*"[^"]*"'), '"password": "***"'),
36
+ (re.compile(r"'password'\s*:\s*'[^']*'"), "'password': '***'"),
37
+ ]
38
+
39
+ def format(self, record: logging.LogRecord) -> str:
40
+ """Format the log record with sensitive data redacted.
41
+
42
+ Args:
43
+ record: The log record to format
44
+
45
+ Returns:
46
+ Formatted log string with sensitive data replaced by ***
47
+ """
48
+ # First, let the parent do standard formatting
49
+ message = super().format(record)
50
+
51
+ # Apply sanitization from validation module
52
+ message = sanitize_log_message(message)
53
+
54
+ # Apply additional formatting-specific patterns
55
+ for pattern, replacement in self.EXTRA_PATTERNS:
56
+ message = pattern.sub(replacement, message)
57
+
58
+ return message
59
+
60
+
61
+ class SecureLoggerAdapter(logging.LoggerAdapter):
62
+ """Logger adapter that sanitizes messages before logging.
63
+
64
+ This adapter wraps a logger and ensures all messages are sanitized
65
+ before being passed to the underlying logger.
66
+
67
+ Example:
68
+ >>> logger = logging.getLogger("myapp")
69
+ >>> secure_logger = SecureLoggerAdapter(logger)
70
+ >>> secure_logger.info("Password is secret123")
71
+ # Message will be sanitized before logging
72
+ """
73
+
74
+ def process(self, msg: str, kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
75
+ """Process the logging message to sanitize sensitive data.
76
+
77
+ Args:
78
+ msg: The log message
79
+ kwargs: Additional keyword arguments
80
+
81
+ Returns:
82
+ Tuple of (sanitized message, kwargs)
83
+ """
84
+ sanitized_msg = sanitize_log_message(str(msg))
85
+ return sanitized_msg, kwargs
86
+
87
+
88
+ def configure_secure_logging(
89
+ level: int = logging.INFO,
90
+ format_string: str | None = None,
91
+ logger_name: str | None = None,
92
+ ) -> logging.Logger:
93
+ """Configure logging with secure formatter.
94
+
95
+ Sets up logging with automatic secret redaction. This should be called
96
+ early in application startup.
97
+
98
+ Args:
99
+ level: Logging level (default: INFO)
100
+ format_string: Custom format string (default: standard format)
101
+ logger_name: Logger name to configure (default: root logger)
102
+
103
+ Returns:
104
+ The configured logger
105
+
106
+ Example:
107
+ >>> logger = configure_secure_logging(logging.DEBUG)
108
+ >>> logger.info("Connecting to postgresql://user:secret@host/db")
109
+ # Output: 2024-01-15 10:30:00 - INFO - Connecting to postgresql://***@host/db
110
+ """
111
+ if format_string is None:
112
+ format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
113
+
114
+ # Create secure formatter
115
+ formatter = SecureFormatter(format_string)
116
+
117
+ # Create handler with secure formatter
118
+ handler = logging.StreamHandler()
119
+ handler.setFormatter(formatter)
120
+ handler.setLevel(level)
121
+
122
+ # Get logger
123
+ logger = logging.getLogger(logger_name)
124
+
125
+ # Remove existing handlers to avoid duplicates
126
+ logger.handlers.clear()
127
+
128
+ # Add secure handler
129
+ logger.addHandler(handler)
130
+ logger.setLevel(level)
131
+
132
+ return logger
133
+
134
+
135
+ def get_secure_logger(name: str) -> SecureLoggerAdapter:
136
+ """Get a logger wrapped with secure adapter.
137
+
138
+ Args:
139
+ name: Logger name
140
+
141
+ Returns:
142
+ SecureLoggerAdapter wrapping the named logger
143
+
144
+ Example:
145
+ >>> logger = get_secure_logger("confiture.migration")
146
+ >>> logger.info("Running with password=secret123")
147
+ # password will be redacted
148
+ """
149
+ base_logger = logging.getLogger(name)
150
+ return SecureLoggerAdapter(base_logger, {})
151
+
152
+
153
+ class SensitiveValue:
154
+ """Wrapper for sensitive values that redacts them in string representation.
155
+
156
+ Use this to wrap sensitive values that might accidentally be logged
157
+ or included in error messages.
158
+
159
+ Example:
160
+ >>> password = SensitiveValue("secret123")
161
+ >>> print(f"Password is {password}")
162
+ Password is ***
163
+ >>> str(password)
164
+ '***'
165
+ >>> password.get_value()
166
+ 'secret123'
167
+ """
168
+
169
+ def __init__(self, value: Any):
170
+ """Initialize with a sensitive value.
171
+
172
+ Args:
173
+ value: The sensitive value to wrap
174
+ """
175
+ self._value = value
176
+
177
+ def get_value(self) -> Any:
178
+ """Get the actual sensitive value.
179
+
180
+ Returns:
181
+ The wrapped value
182
+ """
183
+ return self._value
184
+
185
+ def __str__(self) -> str:
186
+ """Return redacted string representation."""
187
+ return "***"
188
+
189
+ def __repr__(self) -> str:
190
+ """Return redacted repr."""
191
+ return "SensitiveValue(***)"
192
+
193
+ def __eq__(self, other: Any) -> bool:
194
+ """Compare values."""
195
+ if isinstance(other, SensitiveValue):
196
+ return self._value == other._value
197
+ return self._value == other
198
+
199
+ def __hash__(self) -> int:
200
+ """Hash the value."""
201
+ return hash(self._value)
@@ -0,0 +1,416 @@
1
+ """Input validation for security.
2
+
3
+ This module provides validation functions to prevent common security issues:
4
+ - SQL injection via identifier validation
5
+ - Path traversal via path validation
6
+ - Configuration tampering via config validation
7
+ - Secret exposure via log sanitization
8
+
9
+ Note: These are defense-in-depth measures. Always use parameterized queries
10
+ as the primary defense against SQL injection.
11
+ """
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # Allowed characters in SQL identifiers
18
+ # Matches: starts with letter/underscore, contains only alphanumeric and underscore
19
+ IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
20
+
21
+ # Maximum lengths
22
+ MAX_IDENTIFIER_LENGTH = 63 # PostgreSQL limit
23
+ MAX_PATH_LENGTH = 4096
24
+ MAX_SQL_LENGTH = 10_000_000 # 10MB
25
+
26
+ # PostgreSQL reserved words that cannot be used as identifiers
27
+ # This is a subset of the most common ones
28
+ RESERVED_WORDS = frozenset(
29
+ {
30
+ "select",
31
+ "insert",
32
+ "update",
33
+ "delete",
34
+ "drop",
35
+ "create",
36
+ "alter",
37
+ "truncate",
38
+ "grant",
39
+ "revoke",
40
+ "table",
41
+ "index",
42
+ "view",
43
+ "sequence",
44
+ "schema",
45
+ "database",
46
+ "user",
47
+ "role",
48
+ "from",
49
+ "where",
50
+ "and",
51
+ "or",
52
+ "not",
53
+ "null",
54
+ "true",
55
+ "false",
56
+ "in",
57
+ "is",
58
+ "as",
59
+ "on",
60
+ "join",
61
+ "left",
62
+ "right",
63
+ "inner",
64
+ "outer",
65
+ "full",
66
+ "cross",
67
+ "union",
68
+ "except",
69
+ "intersect",
70
+ "order",
71
+ "by",
72
+ "group",
73
+ "having",
74
+ "limit",
75
+ "offset",
76
+ "for",
77
+ "with",
78
+ "returning",
79
+ "into",
80
+ "values",
81
+ "set",
82
+ "default",
83
+ "constraint",
84
+ "primary",
85
+ "foreign",
86
+ "key",
87
+ "references",
88
+ "unique",
89
+ "check",
90
+ "cascade",
91
+ "restrict",
92
+ }
93
+ )
94
+
95
+ # Dangerous SQL patterns (defense in depth)
96
+ DANGEROUS_PATTERNS = [
97
+ (re.compile(r";\s*DROP\s+", re.IGNORECASE), "DROP statement after semicolon"),
98
+ (re.compile(r";\s*DELETE\s+FROM\s+", re.IGNORECASE), "DELETE statement after semicolon"),
99
+ (re.compile(r";\s*TRUNCATE\s+", re.IGNORECASE), "TRUNCATE statement after semicolon"),
100
+ (re.compile(r";\s*ALTER\s+.*\s+OWNER\s+", re.IGNORECASE), "ALTER OWNER after semicolon"),
101
+ (re.compile(r"--[^\n]*", re.MULTILINE), "SQL comment (potential injection)"),
102
+ (re.compile(r"/\*.*?\*/", re.DOTALL), "SQL block comment"),
103
+ ]
104
+
105
+ # Patterns for sanitizing sensitive data in logs
106
+ SENSITIVE_PATTERNS = [
107
+ (re.compile(r"password[=:]\s*\S+", re.IGNORECASE), "password=***"),
108
+ (re.compile(r"passwd[=:]\s*\S+", re.IGNORECASE), "passwd=***"),
109
+ (re.compile(r"secret[=:]\s*\S+", re.IGNORECASE), "secret=***"),
110
+ (re.compile(r"token[=:]\s*\S+", re.IGNORECASE), "token=***"),
111
+ (re.compile(r"api[_-]?key[=:]\s*\S+", re.IGNORECASE), "api_key=***"),
112
+ (re.compile(r"auth[_-]?token[=:]\s*\S+", re.IGNORECASE), "auth_token=***"),
113
+ (re.compile(r"access[_-]?key[=:]\s*\S+", re.IGNORECASE), "access_key=***"),
114
+ (re.compile(r"private[_-]?key[=:]\s*\S+", re.IGNORECASE), "private_key=***"),
115
+ (re.compile(r"postgresql://[^@]+@"), "postgresql://***@"),
116
+ (re.compile(r"postgres://[^@]+@"), "postgres://***@"),
117
+ (re.compile(r"Bearer\s+[\w\-_.]+", re.IGNORECASE), "Bearer ***"),
118
+ (re.compile(r"Basic\s+[A-Za-z0-9+/=]+", re.IGNORECASE), "Basic ***"),
119
+ ]
120
+
121
+
122
+ class ValidationError(Exception):
123
+ """Raised when validation fails.
124
+
125
+ Attributes:
126
+ message: Description of the validation failure
127
+ field: Optional field name that failed validation
128
+ """
129
+
130
+ def __init__(self, message: str, field: str | None = None):
131
+ self.message = message
132
+ self.field = field
133
+ super().__init__(message)
134
+
135
+
136
+ def validate_identifier(name: str, context: str = "identifier") -> str:
137
+ """Validate SQL identifier (table, column, schema name).
138
+
139
+ Ensures the identifier:
140
+ - Is not empty
141
+ - Does not exceed PostgreSQL's 63 character limit
142
+ - Contains only valid characters (alphanumeric and underscore)
143
+ - Starts with letter or underscore
144
+ - Is not a reserved SQL word
145
+
146
+ Args:
147
+ name: The identifier to validate
148
+ context: Description for error messages (e.g., "table name", "column name")
149
+
150
+ Returns:
151
+ The validated identifier (unchanged if valid)
152
+
153
+ Raises:
154
+ ValidationError: If validation fails
155
+
156
+ Examples:
157
+ >>> validate_identifier("users")
158
+ 'users'
159
+ >>> validate_identifier("user_accounts")
160
+ 'user_accounts'
161
+ >>> validate_identifier("'; DROP TABLE users; --")
162
+ Raises ValidationError
163
+ """
164
+ if not name:
165
+ raise ValidationError(f"Empty {context}", field=context)
166
+
167
+ if not isinstance(name, str):
168
+ raise ValidationError(
169
+ f"{context} must be a string, got {type(name).__name__}", field=context
170
+ )
171
+
172
+ if len(name) > MAX_IDENTIFIER_LENGTH:
173
+ raise ValidationError(
174
+ f"{context} exceeds maximum length of {MAX_IDENTIFIER_LENGTH} characters",
175
+ field=context,
176
+ )
177
+
178
+ if not IDENTIFIER_PATTERN.match(name):
179
+ raise ValidationError(
180
+ f"Invalid {context}: '{name}'. Must start with letter or underscore, "
181
+ "and contain only alphanumeric characters and underscores",
182
+ field=context,
183
+ )
184
+
185
+ # Check for reserved words
186
+ if name.lower() in RESERVED_WORDS:
187
+ raise ValidationError(
188
+ f"{context} '{name}' is a SQL reserved word. Use a different name or quote it.",
189
+ field=context,
190
+ )
191
+
192
+ return name
193
+
194
+
195
+ def validate_path(path: str | Path, must_exist: bool = False, base_dir: Path | None = None) -> Path:
196
+ """Validate file path for safety.
197
+
198
+ Ensures the path:
199
+ - Is not too long
200
+ - Does not contain null bytes
201
+ - Resolves to a valid path
202
+ - Optionally exists
203
+ - Optionally is within a base directory (prevents traversal)
204
+
205
+ Args:
206
+ path: The path to validate
207
+ must_exist: If True, path must exist on filesystem
208
+ base_dir: If provided, path must be within this directory
209
+
210
+ Returns:
211
+ The validated Path object (resolved to absolute)
212
+
213
+ Raises:
214
+ ValidationError: If validation fails
215
+
216
+ Examples:
217
+ >>> validate_path("db/migrations/001.py")
218
+ PosixPath('/absolute/path/to/db/migrations/001.py')
219
+ >>> validate_path("../../../etc/passwd", base_dir=Path("db"))
220
+ Raises ValidationError
221
+ """
222
+ if isinstance(path, str):
223
+ path = Path(path)
224
+
225
+ path_str = str(path)
226
+
227
+ # Check length
228
+ if len(path_str) > MAX_PATH_LENGTH:
229
+ raise ValidationError(f"Path exceeds maximum length of {MAX_PATH_LENGTH} characters")
230
+
231
+ # Check for null bytes (common injection technique)
232
+ if "\x00" in path_str:
233
+ raise ValidationError("Path contains null byte")
234
+
235
+ # Resolve to absolute path
236
+ try:
237
+ resolved = path.resolve()
238
+ except (OSError, ValueError) as e:
239
+ raise ValidationError(f"Invalid path: {e}") from e
240
+
241
+ # Check for path traversal if base_dir is specified
242
+ if base_dir is not None:
243
+ base_resolved = base_dir.resolve()
244
+ try:
245
+ resolved.relative_to(base_resolved)
246
+ except ValueError:
247
+ raise ValidationError(
248
+ f"Path '{path}' is outside allowed directory '{base_dir}'"
249
+ ) from None
250
+
251
+ # Check existence if required
252
+ if must_exist and not resolved.exists():
253
+ raise ValidationError(f"Path does not exist: {path}")
254
+
255
+ return resolved
256
+
257
+
258
+ def validate_environment(env: str) -> str:
259
+ """Validate environment name.
260
+
261
+ Ensures the environment is one of the allowed values.
262
+
263
+ Args:
264
+ env: Environment name to validate
265
+
266
+ Returns:
267
+ Validated environment name (lowercase)
268
+
269
+ Raises:
270
+ ValidationError: If environment is not allowed
271
+
272
+ Examples:
273
+ >>> validate_environment("production")
274
+ 'production'
275
+ >>> validate_environment("STAGING")
276
+ 'staging'
277
+ >>> validate_environment("hacker")
278
+ Raises ValidationError
279
+ """
280
+ if not env:
281
+ raise ValidationError("Empty environment name")
282
+
283
+ if not isinstance(env, str):
284
+ raise ValidationError(f"Environment must be a string, got {type(env).__name__}")
285
+
286
+ allowed = {"local", "development", "dev", "test", "testing", "staging", "production", "prod"}
287
+
288
+ env_lower = env.lower().strip()
289
+
290
+ if env_lower not in allowed:
291
+ raise ValidationError(
292
+ f"Invalid environment: '{env}'. Allowed: {', '.join(sorted(allowed))}"
293
+ )
294
+
295
+ return env_lower
296
+
297
+
298
+ def validate_sql(sql: str, allow_dangerous: bool = False) -> str:
299
+ """Validate SQL for basic safety.
300
+
301
+ This is a defense-in-depth measure, NOT a replacement for parameterized queries.
302
+ It checks for:
303
+ - Empty SQL
304
+ - Excessive length
305
+ - Dangerous patterns (multiple statements, comments)
306
+
307
+ Args:
308
+ sql: SQL string to validate
309
+ allow_dangerous: If True, skip dangerous pattern checks
310
+ (use for trusted migration SQL)
311
+
312
+ Returns:
313
+ Validated SQL string
314
+
315
+ Raises:
316
+ ValidationError: If validation fails
317
+
318
+ Examples:
319
+ >>> validate_sql("SELECT * FROM users")
320
+ 'SELECT * FROM users'
321
+ >>> validate_sql("SELECT 1; DROP TABLE users;")
322
+ Raises ValidationError (unless allow_dangerous=True)
323
+ """
324
+ if not sql:
325
+ raise ValidationError("Empty SQL")
326
+
327
+ if not isinstance(sql, str):
328
+ raise ValidationError(f"SQL must be a string, got {type(sql).__name__}")
329
+
330
+ sql = sql.strip()
331
+
332
+ if not sql:
333
+ raise ValidationError("SQL contains only whitespace")
334
+
335
+ if len(sql) > MAX_SQL_LENGTH:
336
+ raise ValidationError(f"SQL exceeds maximum length of {MAX_SQL_LENGTH} characters")
337
+
338
+ if not allow_dangerous:
339
+ for pattern, description in DANGEROUS_PATTERNS:
340
+ if pattern.search(sql):
341
+ raise ValidationError(f"SQL contains potentially dangerous pattern: {description}")
342
+
343
+ return sql
344
+
345
+
346
+ def validate_config(config: dict[str, Any]) -> dict[str, Any]:
347
+ """Validate configuration dictionary.
348
+
349
+ Ensures:
350
+ - Required fields are present
351
+ - Database URL has valid scheme
352
+ - Warns about embedded credentials
353
+
354
+ Args:
355
+ config: Configuration dictionary to validate
356
+
357
+ Returns:
358
+ Validated configuration
359
+
360
+ Raises:
361
+ ValidationError: If validation fails
362
+ """
363
+ if not isinstance(config, dict):
364
+ raise ValidationError(f"Config must be a dictionary, got {type(config).__name__}")
365
+
366
+ # Check for database URL (may be in different locations)
367
+ db_url = config.get("database_url") or config.get("database", {}).get("url")
368
+
369
+ if db_url:
370
+ # Validate URL scheme
371
+ if not db_url.startswith(("postgresql://", "postgres://")):
372
+ raise ValidationError(
373
+ "Invalid database URL scheme. Must start with 'postgresql://' or 'postgres://'"
374
+ )
375
+
376
+ # Warn about embedded credentials (but don't reject)
377
+ if "@" in db_url and "://" in db_url:
378
+ import logging
379
+
380
+ logging.getLogger(__name__).warning(
381
+ "Database URL contains embedded credentials. "
382
+ "Consider using environment variables or a password file instead."
383
+ )
384
+
385
+ return config
386
+
387
+
388
+ def sanitize_log_message(message: str) -> str:
389
+ """Remove sensitive data from log messages.
390
+
391
+ Redacts common sensitive patterns like:
392
+ - Passwords
393
+ - API keys/tokens
394
+ - Database URLs with credentials
395
+ - Bearer/Basic auth tokens
396
+
397
+ Args:
398
+ message: Log message to sanitize
399
+
400
+ Returns:
401
+ Sanitized message with sensitive data replaced by ***
402
+
403
+ Examples:
404
+ >>> sanitize_log_message("Connecting to postgresql://user:secret@host/db")
405
+ 'Connecting to postgresql://***@host/db'
406
+ >>> sanitize_log_message("Using token=abc123xyz")
407
+ 'Using token=***'
408
+ """
409
+ if not message:
410
+ return message
411
+
412
+ result = message
413
+ for pattern, replacement in SENSITIVE_PATTERNS:
414
+ result = pattern.sub(replacement, result)
415
+
416
+ return result