fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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.
- confiture/__init__.py +48 -0
- confiture/_core.cpython-311-darwin.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1893 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +184 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +882 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +24 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +265 -0
- confiture/models/schema.py +203 -0
- confiture/models/sql_file_migration.py +225 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +100 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/loader.py +225 -0
- confiture/testing/pytest/__init__.py +38 -0
- confiture/testing/pytest_plugin.py +190 -0
- confiture/testing/sandbox.py +304 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
- fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
- fraiseql_confiture-0.3.7.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
|