Volver a la wiki

Cifrado en reposo de secretos de canales de notificación (sa4 A2, 2ª mitad)

Resumen ejecutivo

La primera mitad de sa4 A2 redactaba los secretos de NotificationChannel.config en la API (GET devolvía REDACTED). La segunda mitad los cifra en la base de datos con Fernet (idéntico al patrón de StoredCredential.encrypted_data), de modo que un dump de BD nunca expone webhooks, tokens o Authorization headers en plaintext.

Cambio: PR #72, commit 1fe6098 (2026-06-07)

Archivos clave:

Motivación (sa4)

sa4 (Auditoría de Seguridad A2) exigía eliminar la exposición de secretos de notificación. Se ejecutó en dos fases:

  1. 1ª mitad (ya hecha): redacción en la API (cliente ve REDACTED, no el token real)
  2. 2ª mitad (este commit): cifrado en reposo en la BD (dump de BD no expone secretos)

Antes de esta feature, un administrator que hacía backup de PostgreSQL podía ver en plaintext:

Ahora todos esos valores están almacenados como tokens Fernet, imposibles de leer sin la clave de desencriptación.

Patrón de cifrado

Estructura del config

NotificationChannel.config es un JSONField con estructura variable según el tipo de canal:

Webhook:

{
  "url": "https://example.com/hook",
  "headers": {
    "Authorization": "Bearer token123",
    "X-Custom": "public-value"
  }
}

Slack / Teams:

{
  "url": "https://hooks.slack.com/...",
  "token": "xoxb-..."
}

Qué se cifra

Solo valores mapeados en _SECRET_CONFIG_KEYS y _SECRET_HEADER_NAMES:

Ejemplo post-cifrado (vista en la BD):

{
  "url": "gAAAAABm1234567890...(token Fernet)...==",
  "headers": {
    "Authorization": "gAAAAABm1234567890...(token Fernet)...==",
    "X-Custom": "public-value"
  }
}

El cliente ve (API GET con redacción + cifrado en BD):

{
  "url": "REDACTED",
  "headers": {
    "Authorization": "REDACTED",
    "X-Custom": "public-value"
  }
}

Migración de datos (monitoring/0021)

Función: encrypt_existing_configs(apps, schema_editor)

Ejecución: Dokploy la aplica automáticamente en el deploy. Si se interrumpe y repite, es segura (idempotente).

Integración con la API

create_channel

NotificationChannel.objects.create(
    ...
    config=encrypt_channel_config(data.config),  # ← Cifra antes de guardar
    ...
)

update_channel

c.config = merge_preserving_secrets(data.config, c.config)  # ← Preserva secretos si cliente envía REDACTED
c.config = encrypt_channel_config(c.config)  # ← (Re)cifra antes de guardar
c.save()

El patrón merge_preserving_secrets + encrypt_channel_config permite que un cliente:

  1. Haga GET (obtiene REDACTED)
  2. Haga cambios a otros campos (name, risk_levels, etc.)
  3. Reenvíe el JSON con REDACTED en lugar del secreto real
  4. PUT → merge preserva el secreto cifrado antiguo, encripta cualquier nuevo plaintext

Integración con el dispatcher

Todos los dispatchers (webhooks, Slack, Teams, email) ahora descifran justo antes de usar:

def _send_webhook(channel, payload):
    cfg = decrypt_channel_config(channel.config)  # ← Descifra en memoria
    url = cfg.get("url", "")  # ← URL en plaintext solo en este punto
    return _post_pinned(url, payload, extra_headers=cfg.get("headers", {}))

Ventana de exposición: reducida a solo la llamada HTTP al webhook (inevitable, el webhook debe recibirla).

Rotation de clave (reencrypt_credentials)

El command manage.py reencrypt_credentials (usado para rotar claves de cifrado) ahora incluye NotificationChannel.config:

def _process_notification_channels(self, dry_run, totals):
    for ch in NotificationChannel.objects.all().iterator():
        # Top-level secrets + headers
        new_config, changed = self._reencrypt_mapping(ch.config, ...)
        if changed and not dry_run:
            ch.config = new_config
            ch.save(update_fields=["config"])

Esto permite cambiar la clave Fernet sin perder secretos (desencripta con clave vieja, encripta con clave nueva).

Testing

Clase: tests/api/test_monitoring_sa4_sa5.py::TestChannelConfigEncryption

  1. test_secret_is_encrypted_at_rest:

    • POST /api/.../channels con URL plaintext
    • En la BD, la URL debe ser un token Fernet (verificado con CredentialManager.is_encrypted(...))
    • El plaintext “hooks.example” no aparece en el JSON almacenado
  2. test_dispatch_roundtrip_decrypts:

    • Crea config con URL + headers secretos
    • Encripta
    • Desencripta
    • Verifica que los valores originales se recuperan exactamente
    • Non-secret fields (e.g., addresses: []) quedan intactos
  3. test_encrypt_is_idempotent:

    • Encripta dos veces
    • Resultado idéntico (no dobla-encriptación)

Suite entera: 318 tests passed, 1 skipped (sa4/sa5 + cobertura general).

Huella de riesgo (smoke test en STAGE obligatorio)

El CHANGELOG advierte:

⚠️ Toca el envío de notificaciones en vivo → requiere smoke en STAGE antes del merge (footgun auth/crypto).

Riesgos residuales:

Smoke test cobertura:

Calendario

Véase también

Subir