新增了完整的认证工具库模块,包含以下功能: 1. 指数退避重试组件 2. 敏感数据过滤与日志脱敏 3. 安全策略管理引擎 4. 认证健康监控模块 5. SSRF防护工具集 6. 多类型token管理系统 7. 密钥管理器加解密工具
207 lines
6.6 KiB
Python
207 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
_SENSITIVE_PATTERNS = [
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"key",
|
|
"credential",
|
|
"api_key",
|
|
"app_secret",
|
|
"bot_token",
|
|
"access_token",
|
|
"refresh_token",
|
|
"private_key",
|
|
"signature",
|
|
"nsec",
|
|
"code",
|
|
]
|
|
|
|
_MASK_LENGTH = 4
|
|
_MASK_CHAR = "*"
|
|
|
|
|
|
class SecretSource(Enum):
|
|
CONFIG = "config"
|
|
ENV = "env"
|
|
FILE = "file"
|
|
EXEC = "exec"
|
|
KMS = "kms"
|
|
SECRET_REF = "secret_ref"
|
|
|
|
|
|
class SecretManager:
|
|
def __init__(self, fernet_key: str | None = None):
|
|
self._fernet: Fernet | None = None
|
|
if fernet_key:
|
|
self._init_fernet(fernet_key)
|
|
else:
|
|
env_key = os.getenv("YUXI_FERNET_KEY")
|
|
if env_key:
|
|
self._init_fernet(env_key)
|
|
else:
|
|
logger.warning(
|
|
"[SecretManager] No Fernet key configured. "
|
|
"Credentials will be stored in plaintext. "
|
|
"Set YUXI_FERNET_KEY env var or provide fernet_key parameter."
|
|
)
|
|
|
|
def _init_fernet(self, key: str) -> None:
|
|
try:
|
|
self._fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
|
logger.info("[SecretManager] Fernet encryption initialized")
|
|
except Exception as e:
|
|
logger.error(f"[SecretManager] Failed to initialize Fernet: {e}")
|
|
self._fernet = None
|
|
|
|
@property
|
|
def is_encryption_available(self) -> bool:
|
|
return self._fernet is not None
|
|
|
|
async def resolve_secret(
|
|
self,
|
|
key: str,
|
|
sources: list[SecretSource] | None = None,
|
|
config: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
if sources is None:
|
|
sources = [SecretSource.CONFIG, SecretSource.ENV]
|
|
|
|
for source in sources:
|
|
if source == SecretSource.CONFIG and config:
|
|
value = config.get(key)
|
|
if value:
|
|
return value
|
|
|
|
if source == SecretSource.ENV:
|
|
value = os.getenv(key) or os.getenv(key.upper())
|
|
if value:
|
|
return value
|
|
|
|
if source == SecretSource.FILE and config:
|
|
file_path = config.get(f"{key}_file")
|
|
if file_path and os.path.isfile(file_path):
|
|
try:
|
|
with open(file_path, encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except OSError as e:
|
|
logger.warning(f"[SecretManager] Failed to read secret file {file_path}: {e}")
|
|
|
|
if source == SecretSource.SECRET_REF and config:
|
|
ref = config.get(key)
|
|
if isinstance(ref, str) and ref.startswith("secret://"):
|
|
resolved = await self._resolve_secret_ref(ref)
|
|
if resolved:
|
|
return resolved
|
|
|
|
if source == SecretSource.EXEC and config:
|
|
exec_cmd = config.get(f"{key}_exec")
|
|
if exec_cmd:
|
|
import subprocess
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
exec_cmd,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
logger.warning(f"[SecretManager] Exec command for {key} failed: {result.stderr}")
|
|
except Exception as e:
|
|
logger.warning(f"[SecretManager] Failed to exec {exec_cmd}: {e}")
|
|
|
|
return None
|
|
|
|
async def _resolve_secret_ref(self, ref: str) -> str | None:
|
|
path = ref.removeprefix("secret://")
|
|
if os.path.isfile(path):
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
decrypted = f.read().strip()
|
|
if self._fernet and self._looks_encrypted(decrypted):
|
|
decrypted = self.decrypt_secret(decrypted)
|
|
return decrypted
|
|
except OSError as e:
|
|
logger.warning(f"[SecretManager] Failed to read secret ref {path}: {e}")
|
|
return None
|
|
|
|
def encrypt_secret(self, value: str) -> str:
|
|
if not self._fernet:
|
|
logger.warning("[SecretManager] Encryption not available, storing plaintext")
|
|
return value
|
|
encrypted = self._fernet.encrypt(value.encode())
|
|
return base64.urlsafe_b64encode(encrypted).decode()
|
|
|
|
def decrypt_secret(self, encrypted: str) -> str:
|
|
if not self._fernet:
|
|
return encrypted
|
|
try:
|
|
decoded = base64.urlsafe_b64decode(encrypted.encode())
|
|
return self._fernet.decrypt(decoded).decode()
|
|
except Exception:
|
|
return encrypted
|
|
|
|
@staticmethod
|
|
def redact(value: str | None) -> str:
|
|
if not value:
|
|
return "***"
|
|
if len(value) <= _MASK_LENGTH * 2:
|
|
return _MASK_CHAR * (len(value) if len(value) < 8 else 8)
|
|
return value[:_MASK_LENGTH] + _MASK_CHAR * 8 + value[-_MASK_LENGTH:]
|
|
|
|
@staticmethod
|
|
def is_sensitive_key(key: str) -> bool:
|
|
key_lower = key.lower()
|
|
return any(pattern in key_lower for pattern in _SENSITIVE_PATTERNS)
|
|
|
|
@staticmethod
|
|
def _looks_encrypted(value: str) -> bool:
|
|
try:
|
|
decoded = base64.urlsafe_b64decode(value.encode())
|
|
return len(decoded) > 32
|
|
except Exception:
|
|
return False
|
|
|
|
@staticmethod
|
|
def redact_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
result = {}
|
|
for k, v in config.items():
|
|
if SecretManager.is_sensitive_key(k):
|
|
if isinstance(v, str):
|
|
result[k] = SecretManager.redact(v)
|
|
else:
|
|
result[k] = "***"
|
|
elif isinstance(v, dict):
|
|
result[k] = SecretManager.redact_config(v)
|
|
else:
|
|
result[k] = v
|
|
return result
|
|
|
|
|
|
_secret_manager: SecretManager | None = None
|
|
|
|
|
|
def get_secret_manager() -> SecretManager:
|
|
global _secret_manager
|
|
if _secret_manager is None:
|
|
_secret_manager = SecretManager()
|
|
return _secret_manager
|
|
|
|
|
|
def init_secret_manager(fernet_key: str | None = None) -> SecretManager:
|
|
global _secret_manager
|
|
_secret_manager = SecretManager(fernet_key=fernet_key)
|
|
return _secret_manager
|