CRITICAL (CVE-class): - C-4: Remove DEV_MODE fallback with hardcoded postgres:postgres credentials - C-3: WebSocket endpoints now require JWT or API key token (?token=) auth - C-1: Superset environment passwords encrypted via Fernet at rest in DB - H-4: SESSION_SECRET_KEY separated from JWT AUTH_SECRET_KEY - H-1: Log injection via %s-formatting instead of f-strings - H-5: X-Trace-ID header validated as UUID4 to prevent trace poisoning INFRASTRUCTURE: - New src/core/encryption.py — EncryptionManager extracted from llm_provider - Test DB: per-module sqlite:///:memory: with PRAGMA foreign_keys=ON - testcontainers PostgreSQL via TEST_DB=postgres env var - conftest temp-file global engine (no 10GB shared-cache leak) TEST FIXES (44 pre-existing → 0): - test_smoke_plugins: module-level sys.modules mock isolated to per-test fixture - test_migration_engine: EXT:Python:uuid → uuid syntax fix - test_dashboards_api: mock env attributes + correct patch target - test_constants_audit_fixes: sync expected constant names with actual - test_defensive_guards: patch.object instead of module-level Repo mock - test_clean_release_cli: removed empty config.json directory - FK violations: TaskRecord parents in log_persistence, Environment in mapping_service, ReleaseCandidate in candidate_manifest_services ORTHOGONAL TESTS (18 new): - test_security_orthogonal.py: bcrypt 72-byte limit, unicode, API key format invariants, Fernet robustness, encryption key lifecycle, log injection protection, JWT edge cases MODEL FIXES: - MetricSnapshot.job_id: nullable with SET NULL (was broken FK for aggregate prune snapshots, hidden by SQLite) CLEANUP: - Removed stale :memory:test_main/test_auth/test_tasks file databases - Removed duplicate #endregion in encryption_key.py
111 lines
4.6 KiB
Python
111 lines
4.6 KiB
Python
# #region EncryptionCore [C:5] [TYPE Module] [SEMANTICS encryption, fernet, key, crypto, secret]
|
|
# @BRIEF Core encryption infrastructure: Fernet-based EncryptionManager for reversible secret storage.
|
|
# @LAYER Infrastructure
|
|
# @RELATION DEPENDS_ON -> [LoggerModule]
|
|
# @INVARIANT Encryption initialization never falls back to a hardcoded secret.
|
|
# ENCRYPTION_KEY must be set via environment or .env before first use.
|
|
# @PRE Runtime environment has a valid Fernet ENCRYPTION_KEY.
|
|
# @POST EncryptionManager provides encrypt/decrypt operations backed by validated Fernet key.
|
|
# @SIDE_EFFECT Reads ENCRYPTION_KEY from process environment.
|
|
# @DATA_CONTRACT Input[str] -> Encrypted[str] -> Output[str]
|
|
# @RATIONALE Extracted from services/llm_provider.py to decouple core encryption from LLM domain
|
|
# and enable reuse by ConfigManager for Superset password encryption (see [SEC:C-1]).
|
|
|
|
import os
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from .logger import belief_scope, logger
|
|
|
|
|
|
# #region _require_fernet_key [C:5] [TYPE Function]
|
|
# @BRIEF Load and validate the Fernet key used for secret encryption.
|
|
# @PRE ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
|
|
# @POST Returns validated key bytes ready for Fernet initialization.
|
|
# @RELATION DEPENDS_ON -> [LoggerModule]
|
|
# @SIDE_EFFECT Emits belief-state logs for missing or invalid encryption configuration.
|
|
# @DATA_CONTRACT Input[ENCRYPTION_KEY:str] -> Output[bytes]
|
|
# @INVARIANT Encryption initialization never falls back to a hardcoded secret.
|
|
def _require_fernet_key() -> bytes:
|
|
with belief_scope("_require_fernet_key"):
|
|
raw_key = os.getenv("ENCRYPTION_KEY", "").strip()
|
|
if not raw_key:
|
|
logger.explore(
|
|
"Missing ENCRYPTION_KEY blocks EncryptionManager initialization"
|
|
)
|
|
raise RuntimeError(
|
|
"ENCRYPTION_KEY must be set. Run ensure_encryption_key() "
|
|
"during application startup before using EncryptionManager."
|
|
)
|
|
|
|
key = raw_key.encode()
|
|
try:
|
|
Fernet(key)
|
|
except Exception as exc:
|
|
logger.explore(
|
|
"Invalid ENCRYPTION_KEY blocks EncryptionManager initialization"
|
|
)
|
|
raise RuntimeError("ENCRYPTION_KEY must be a valid Fernet key") from exc
|
|
|
|
logger.reflect("Validated ENCRYPTION_KEY for EncryptionManager initialization")
|
|
return key
|
|
|
|
|
|
# #endregion _require_fernet_key
|
|
|
|
|
|
# #region EncryptionManager [C:5] [TYPE Class]
|
|
# @BRIEF Handles encryption and decryption of sensitive data like API keys and passwords.
|
|
# @RELATION CALLS -> [_require_fernet_key]
|
|
# @PRE ENCRYPTION_KEY is configured with a valid Fernet key before instantiation.
|
|
# @POST Manager exposes reversible encrypt/decrypt operations for persisted secrets.
|
|
# @SIDE_EFFECT Initializes Fernet cryptography state from process environment.
|
|
# @DATA_CONTRACT Input[str] -> Output[str]
|
|
# @INVARIANT Uses only a validated secret key from environment.
|
|
#
|
|
# @TEST_CONTRACT EncryptionManagerModel ->
|
|
# {
|
|
# required_fields: {},
|
|
# invariants: [
|
|
# "encrypted data can be decrypted back to the original string"
|
|
# ]
|
|
# }
|
|
# @TEST_FIXTURE basic_encryption_cycle -> {"data": "my_secret_key"}
|
|
# @TEST_EDGE decrypt_invalid_data -> raises Exception
|
|
# @TEST_EDGE empty_string_encryption -> {"data": ""}
|
|
# @TEST_INVARIANT symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption]
|
|
class EncryptionManager:
|
|
# region EncryptionManager_init [TYPE Function]
|
|
# @PURPOSE: Initialize the encryption manager with a Fernet key.
|
|
# @PRE ENCRYPTION_KEY env var must be set to a valid Fernet key.
|
|
# @POST Fernet instance ready for encryption/decryption.
|
|
def __init__(self):
|
|
self.key = _require_fernet_key()
|
|
self.fernet = Fernet(self.key)
|
|
|
|
# endregion EncryptionManager_init
|
|
|
|
# region encrypt [TYPE Function]
|
|
# @PURPOSE: Encrypt a plaintext string.
|
|
# @PRE data must be a non-empty string.
|
|
# @POST Returns encrypted string.
|
|
def encrypt(self, data: str) -> str:
|
|
with belief_scope("encrypt"):
|
|
return self.fernet.encrypt(data.encode()).decode()
|
|
|
|
# endregion encrypt
|
|
|
|
# region decrypt [TYPE Function]
|
|
# @PURPOSE: Decrypt an encrypted string.
|
|
# @PRE encrypted_data must be a valid Fernet-encrypted string.
|
|
# @POST Returns original plaintext string.
|
|
def decrypt(self, encrypted_data: str) -> str:
|
|
with belief_scope("decrypt"):
|
|
return self.fernet.decrypt(encrypted_data.encode()).decode()
|
|
|
|
# endregion decrypt
|
|
|
|
|
|
# #endregion EncryptionManager
|
|
# #endregion EncryptionCore
|