198 lines
6.9 KiB
Python
198 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import re
|
|
from typing import Any
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from sqlmodel import Session, select
|
|
|
|
from ..core.config import settings
|
|
from ..core.redaction import redact_message
|
|
from ..core.time import utc_now
|
|
from ..db.models import Secret
|
|
|
|
|
|
SECRET_LIKE_PATTERN = re.compile(r"(secret|token|api[_-]?key|password|x-api-key)", re.IGNORECASE)
|
|
REDACTED = "[REDACTED]"
|
|
|
|
|
|
def _derive_fernet_key(value: str) -> bytes:
|
|
value = value.strip()
|
|
try:
|
|
Fernet(value.encode("utf-8"))
|
|
return value.encode("utf-8")
|
|
except (ValueError, TypeError):
|
|
digest = hashlib.sha256(value.encode("utf-8")).digest()
|
|
return base64.urlsafe_b64encode(digest)
|
|
|
|
|
|
def _fallback_material() -> str:
|
|
return f"appops-development-secret-store:{settings.db_path}:{settings.data_dir}"
|
|
|
|
|
|
def _active_key_material() -> str:
|
|
if settings.secret_key:
|
|
if settings.environment == "production" and len(settings.secret_key) < 32:
|
|
raise RuntimeError("APPOPS_SECRET_KEY is too weak for production")
|
|
return settings.secret_key
|
|
return _fallback_material()
|
|
|
|
|
|
def _fernet() -> Fernet:
|
|
return Fernet(_derive_fernet_key(_active_key_material()))
|
|
|
|
|
|
def secret_storage_status() -> dict[str, Any]:
|
|
key_source = settings.secret_key_source if settings.secret_key else "missing"
|
|
encrypted = bool(settings.secret_key) or settings.environment != "production"
|
|
key_strong = bool(settings.secret_key and len(settings.secret_key) >= 32)
|
|
production_ready = key_strong and key_source in {"env", "file"}
|
|
warnings: list[str] = []
|
|
if settings.environment == "production" and not settings.secret_key:
|
|
warnings.append("APPOPS_SECRET_KEY is required for production encrypted secret storage.")
|
|
elif settings.environment == "production" and not key_strong:
|
|
warnings.append("APPOPS_SECRET_KEY must contain at least 32 characters in production.")
|
|
if settings.secret_key_source == "file_unreadable":
|
|
warnings.append("APPOPS_SECRET_KEY_FILE could not be read.")
|
|
if settings.environment != "production" and not settings.secret_key:
|
|
key_source = "development_fallback"
|
|
warnings.append("Development fallback key is active; set APPOPS_SECRET_KEY before production.")
|
|
return {
|
|
"encryptedAtRest": encrypted,
|
|
"enabled": encrypted,
|
|
"keySource": key_source,
|
|
"rotationEnabled": settings.secret_rotation_enabled,
|
|
"productionReady": production_ready,
|
|
"status": "ready" if production_ready or settings.environment != "production" else "warning",
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def set_secret(
|
|
session: Session,
|
|
name: str,
|
|
value: str,
|
|
*,
|
|
used_by: list[str] | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
commit: bool = True,
|
|
) -> Secret:
|
|
clean_name = name.strip().upper()
|
|
clean_value = value.strip()
|
|
now = utc_now()
|
|
encrypted = _fernet().encrypt(clean_value.encode("utf-8")).decode("utf-8")
|
|
row = session.get(Secret, clean_name)
|
|
if not row:
|
|
row = Secret(id=clean_name, name=clean_name, encrypted_value=encrypted, created_at=now)
|
|
row.name = clean_name
|
|
row.encrypted_value = encrypted
|
|
row.updated_at = now
|
|
row.used_by_json = sorted(set(used_by or row.used_by_json or []))
|
|
row.metadata_json = metadata or row.metadata_json or {}
|
|
session.add(row)
|
|
if commit:
|
|
session.commit()
|
|
return row
|
|
|
|
|
|
def get_secret(session: Session, name: str, *, touch: bool = True) -> str | None:
|
|
row = session.get(Secret, name.strip().upper())
|
|
if not row:
|
|
return None
|
|
try:
|
|
value = _fernet().decrypt(row.encrypted_value.encode("utf-8")).decode("utf-8")
|
|
except InvalidToken:
|
|
return None
|
|
if touch:
|
|
row.last_used_at = utc_now()
|
|
session.add(row)
|
|
session.commit()
|
|
return value
|
|
|
|
|
|
def has_secret(session: Session, name: str) -> bool:
|
|
row = session.get(Secret, name.strip().upper())
|
|
return bool(row and row.encrypted_value)
|
|
|
|
|
|
def delete_secret(session: Session, name: str) -> None:
|
|
row = session.get(Secret, name.strip().upper())
|
|
if row:
|
|
session.delete(row)
|
|
session.commit()
|
|
|
|
|
|
def rotate_secret(session: Session, name: str, new_value: str, *, used_by: list[str] | None = None) -> Secret:
|
|
row = session.get(Secret, name.strip().upper())
|
|
return set_secret(session, name, new_value, used_by=used_by or (row.used_by_json if row else None))
|
|
|
|
|
|
def secret_status(row: Secret) -> dict[str, Any]:
|
|
updated_at = row.updated_at or row.created_at
|
|
age_days = max(0, (utc_now() - updated_at).days) if updated_at else None
|
|
return {
|
|
"secretRef": row.id,
|
|
"name": row.name,
|
|
"hasSecret": bool(row.encrypted_value),
|
|
"status": "configured" if row.encrypted_value else "missing",
|
|
"usedBy": row.used_by_json or [],
|
|
"lastUpdated": row.updated_at.isoformat() if row.updated_at else None,
|
|
"lastUsed": row.last_used_at.isoformat() if row.last_used_at else None,
|
|
"ageDays": age_days,
|
|
"rotationRecommended": bool(age_days is not None and age_days >= 90),
|
|
"metadata": {
|
|
key: value
|
|
for key, value in (row.metadata_json or {}).items()
|
|
if not SECRET_LIKE_PATTERN.search(str(key))
|
|
},
|
|
}
|
|
|
|
|
|
def empty_secret_status(name: str, *, used_by: list[str] | None = None) -> dict[str, Any]:
|
|
return {
|
|
"secretRef": name.strip().upper(),
|
|
"name": name.strip().upper(),
|
|
"hasSecret": False,
|
|
"status": "missing",
|
|
"usedBy": used_by or [],
|
|
"lastUpdated": None,
|
|
"lastUsed": None,
|
|
"ageDays": None,
|
|
"rotationRecommended": False,
|
|
"metadata": {},
|
|
}
|
|
|
|
|
|
def list_secret_status(session: Session) -> list[dict[str, Any]]:
|
|
rows = session.exec(select(Secret).order_by(Secret.name)).all()
|
|
return [secret_status(row) for row in rows]
|
|
|
|
|
|
def redact_secret_value(value: str | None) -> str | None:
|
|
return REDACTED if value else value
|
|
|
|
|
|
def _known_secret_values(session: Session | None) -> list[str]:
|
|
if session is None:
|
|
return []
|
|
values: list[str] = []
|
|
rows = session.exec(select(Secret)).all()
|
|
for row in rows:
|
|
value = get_secret(session, row.id)
|
|
if value:
|
|
values.append(value)
|
|
return values
|
|
|
|
|
|
def redact_text(text: str, *, session: Session | None = None, known_values: list[str] | None = None) -> str:
|
|
redacted = redact_message(text)
|
|
redacted = re.sub(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
|
redacted = re.sub(r"(apiKey\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
|
redacted = re.sub(r"(token\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
|
for value in [*(known_values or []), *_known_secret_values(session)]:
|
|
if value:
|
|
redacted = redacted.replace(value, REDACTED)
|
|
return redacted
|