Arquitecto de automatización con IA: Día 80
Índice de contenido
Resumen:
El inventario de flujos del Día 79 dejó un hallazgo incómodo, “credenciales compartidas”, y al tirar del hilo Valeria descubre que la clave de Claude de las evaluaciones es la misma que la de producción y que la integración de Notion está creada con su cuenta personal. En esta entrada audita el ecosistema entero: lee de los $23$ flujos exportados qué credenciales usa cada uno, completa el inventario con las $26$ llaves de la casa, mide la rotación frente a una política por tipo, compara los permisos concedidos con los necesarios como diferencia de conjuntos ($35$ puertas abiertas de más), revisa las $12$ consolas humanas (doble factor, recuperación, segundo administrador), registra $14$ hallazgos con probabilidad, impacto, severidad y plazo, busca secretos filtrados con patrones y entropía, y reconstruye el ecosistema en un servidor nuevo, sin mirar el viejo, en $2$ horas y $50$ minutos frente a un RTO de $4$ horas. Al terminar, usted sabrá inventariar y auditar las credenciales de un ecosistema de automatizaciones, convertir cada hallazgo en una fecha y un responsable, y medir de verdad cuánto tarda su negocio en volver a funcionar.
¿Cómo citar el presente artículo?
Romero, J. (21 de abril de 2026). Arquitecto de automatización con IA: Día 80. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_80/.
Arquitecto de automatización con IA: Día 80 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_80/.
Precios, límites y políticas revisados en octubre de 2026: planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude. Los comandos n8n import:credentials e import:workflow, la variable N8N_ENCRYPTION_KEY y el hecho de que los workflows importados llegan desactivados se contrastaron con la documentación oficial de n8n; la caducidad de $60$ días de los tokens de acceso de LinkedIn (y de $365$ días de sus tokens de actualización), la opción “nunca” o $60$ días de los tokens de usuario del sistema de Meta, el prefijo ntn_ de los secretos de integración de Notion (los antiguos secret_ siguen valiendo), los límites de gasto mensual por espacio de trabajo de la consola de Claude, la publicación final de NIST SP 800-63B-4 en julio de 2025 y de CIS Controls v8.1 en junio de 2024 se contrastaron con sus páginas oficiales, revisado en octubre de 2026. No se pudo levantar Docker al escribir la entrada: el Bloque 8 es un guion de terminal que se muestra y no se ejecuta, y los tiempos del simulacro (Bloque 9) los declara Valeria como ejemplo. Los otros diez bloques son Python que se ejecuta sin red sobre las exportaciones reales de los días anteriores y sus salidas son las reales. Ningún valor de secreto aparece en la entrada: las claves de ejemplo de los flujos llevan EJEMPLO-NO-REAL dentro del valor y los hallazgos del detector se muestran enmascarados.
Día 80. Auditoría de seguridad del ecosistema: secretos, permisos y simulacro de restauración
Introducción
Ayer, en el Día 79 (Proyecto integrador II: inventario de flujos y gobierno), Valeria leyó automáticamente los $23$ flujos activos de Trigo Lento desde sus exportaciones, les puso dueño, suplente y criticidad, dibujó el grafo de quién llama a quién y escribió la matriz RACI y el calendario de gobierno, y Trigo Lento ganó inventario_flujos.md, un registro que se regenera desde los JSON y que ya nadie tiene que recordar de memoria. La Tabla 3 de ese día cerraba con diez hallazgos, y uno de ellos, el segundo, decía “credenciales compartidas con escritura: Postgres TL en $5$ flujos; Claude x-api-key TL en $6$”. Hoy se tira de ese hilo.
Es martes 21 de abril, a las ocho de la mañana. Valeria abre la consola de Claude para ver cuántas claves existen y encuentra una sola, creada el 2 de febrero, el día en que llamó al modelo por primera vez desde su cuaderno. La credencial Claude x-api-key TL de n8n, la del nodo del agente, la del llavero de Make y la que usa TL Evaluación del agente v1 apuntan todas a esa clave: la pista de cuatro caracteres que la consola muestra al final, a7Qx, es la misma en todas partes. Después abre Notion para comprobar la integración del indexador del Día 68 y descubre que la creó desde su cuenta personal, no desde la del negocio, y que en el Día 71, al conectar el calendario editorial, alguien conectó la página madre “Trigo Lento” entera, con sus catorce páginas hijas, incluida la de nómina. Y en el registrador del dominio la cuenta está a nombre de su padre, con un correo personal y una contraseña de 2019.
Don Ernesto la encuentra con las tres pestañas abiertas y entiende el gesto antes que la explicación. “Las llaves del obrador las cuento yo cada noche: la de la puerta, la del horno, la de la alacena de la harina. Sé cuántas son, quién tiene copia y cuándo cambié la cerradura. ¿Cuántas llaves de esta casa andan por ahí? ¿Y si se pierde una, cuánto tardamos en cambiar la cerradura?” Son las dos preguntas de una auditoría de seguridad, y las dos se responden con un inventario y con un cronómetro, no con buenas intenciones.
En la presente entrada, descubrirá cómo se construye el inventario completo de credenciales de un ecosistema a partir de los mismos JSON que alimentaron el inventario de flujos, qué se añade a mano (las cuentas humanas, las llaves de la infraestructura) y qué se comprueba contra la política de rotación y las caducidades propias de cada proveedor. Verá los permisos como conjuntos, para que el exceso sea una diferencia y no una opinión; revisará las doce consolas de Trigo Lento con cinco controles; convertirá cada hallazgo en una severidad y en una fecha límite; buscará secretos filtrados con patrones de prefijos conocidos y con la entropía de Shannon; y, en la segunda mitad del día, levantará el ecosistema entero en un servidor nuevo, sin mirar el viejo, con el cronómetro en marcha. Terminará con auditoria_seguridad_2026-04.md, con un resumen de una página para Don Ernesto y una cita en el calendario de gobierno.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Construir el inventario completo de credenciales del ecosistema a partir del inventario de flujos del Día 79: tipo, sistema, alcance, dueño, fecha de creación y de última rotación.
- Auditar rotación, alcances, titularidad de las cuentas y doble factor, y registrar cada hallazgo con evidencia, severidad y plazo de corrección.
- Buscar secretos filtrados en exportaciones, repositorio y registros con patrones de prefijos conocidos.
- Ejecutar un simulacro de restauración del ecosistema entero en un servidor nuevo, medir el tiempo de recuperación (RTO) y la pérdida de datos (RPO) y compararlos con los objetivos.
Alcance y método de la auditoría
Una auditoría de seguridad es una comparación sistemática entre lo que hay y lo que debería haber, con evidencia de cada diferencia. Las tres palabras importan. Sistemática: se recorre una lista cerrada, no lo que uno recuerda; por eso la auditoría parte del inventario de flujos del Día 79 y no de la memoria de Valeria. Evidencia: cada hallazgo apunta a una exportación, a una pantalla de una consola o a un registro, de modo que otra persona pueda comprobarlo. Debería haber: hace falta un criterio contra el que comparar, y Trigo Lento tiene tres: el código de honor del Día 6 (secretos fuera del código, mínimo privilegio, registro sin contenido sensible), los controles que la propia serie construyó en los días 17, 35 y 64, y los Controles Críticos de Seguridad del Center for Internet Security (CIS Controls, versión 8.1, junio de 2024), en particular el control 5 (gestión de cuentas), el 6 (gestión del control de acceso) y el 11 (recuperación de datos).
El alcance de hoy cabe en cuatro columnas: credenciales (claves de API, tokens, secretos de firma, contraseñas, llaves SSH), cuentas humanas (las consolas donde alguien entra con usuario y contraseña), permisos (qué puede hacer cada credencial frente a qué necesita) y copias (si lo que se respalda se puede restaurar y cuánto se tarda). Queda fuera, a propósito, lo que ya se explicó y aquí solo se verifica: la teoría de OAuth y del mínimo privilegio es del Día 17, el cifrado de credenciales de n8n con N8N_ENCRYPTION_KEY y la regla 3-2-1 son del Día 35 y la inyección de instrucciones es del Día 64. Hoy no se vuelve a explicar por qué un alcance de lectura es mejor que uno de escritura; se cuenta cuántos alcances de escritura hay donde bastaba leer.
Una auditoría de seguridad revisa sistemas y cuentas, no la conducta de las personas: la pregunta es “qué puede hacer esta llave” y no “quién la usó”. Los valores de las credenciales no se copian nunca en el informe, en la entrada ni en el cuaderno: se trabaja con sus nombres, sus dueños, sus alcances, sus fechas y, como mucho, con la pista de cuatro caracteres que la consola enseña. Si el detector del Bloque 7 encuentra un secreto real, el orden es siempre el mismo: primero se revoca, después se investiga de dónde salió (Día 42). Y el simulacro se hace con una copia cifrada que se borra al terminar, en un servidor que se destruye al final del día.
La auditoría se escribe en el mismo cuaderno de siempre. El primer bloque fija las importaciones, la semilla, la fecha y las dos citas del calendario de gobierno del Día 79 contra las que se medirá cada caducidad:
# Importaciones base, semilla de la serie, fecha de la auditoría y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import math
import re
from collections import Counter, defaultdict
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Circle, Patch
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 170)
pd.set_option('display.max_colwidth', 44)
carpeta = Path('trigo_lento') / 'dia_80'
carpeta.mkdir(parents = True, exist_ok = True)
hoy = date(2026, 4, 21)
REVISION_MENSUAL, REVISION_TRIMESTRAL = date(2026, 5, 4), date(2026, 7, 6) # calendario de gobierno del Día 79
DIAS = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
def fecha_dia(n): # fecha de publicación del Día n de la serie
return date(2026, 2, 1) + timedelta(days = n - 1)
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Auditoría: {DIAS[hoy.weekday()]} {hoy:%d/%m/%Y} | próxima revisión mensual: {REVISION_MENSUAL:%d/%m} | '
f'trimestral (precios, permisos y credenciales): {REVISION_TRIMESTRAL:%d/%m}')
numpy 2.4.6 | pandas 3.0.3
Auditoría: martes 21/04/2026 | próxima revisión mensual: 04/05 | trimestral (precios, permisos y credenciales): 06/07
Martes 21 de abril: la revisión mensual de costes es el lunes 4 de mayo y la trimestral de precios, permisos y credenciales, el lunes 6 de julio. Toda credencial que caduque antes del 6 de julio tiene que tener dueño y fecha antes de que termine el día de hoy, porque la siguiente vez que alguien la mire por rutina ya será tarde.
El inventario de credenciales
El inventario de flujos del Día 79 se leyó de las exportaciones, y el de credenciales empieza igual. Cada nodo de n8n que usa una credencial lleva un campo credentials con el tipo (httpHeaderAuth, postgres, oAuth2Api) y el nombre que Valeria le puso al crearla; la exportación no incluye el secreto, pero sí el nombre y el identificador, y eso basta para saber qué flujo usa qué llave. En Make, cada módulo que usa una conexión lleva __IMTCONN__ en sus parámetros (con el identificador sustituido por 0 en los blueprints publicados, como manda la serie desde el Día 22), y el nombre del módulo dice la aplicación: google-sheets:addRow es la conexión de Google Sheets. Lo que los JSON no saben es lo que no vive en un flujo: la llave SSH del servidor, la clave de cifrado, las cuentas del proveedor del servidor, del dominio y del repositorio, la clave de Voyage AI del indexador, el token del servidor MCP. Esas las declara Valeria a mano, con la misma ficha. El bloque lee los $23$ flujos activos con los mismos criterios de ayer, extrae las referencias y las une con la lista declarada:
# Extracción de las credenciales referenciadas en los JSON exportados y unión con las cuentas humanas en Python:
def modulos_make(flujo): # recorre módulos, rutas de routers y manejadores
for m in flujo:
yield m
for manejador in m.get('onerror') or []:
yield manejador
for ruta in m.get('routes') or []:
yield from modulos_make(ruta['flow'])
def leer_exportacion(ruta):
datos = json.loads(ruta.read_text(encoding = 'utf-8'))
for wf in (datos if isinstance(datos, list) else [datos]):
if isinstance(wf, dict) and isinstance(wf.get('flow'), list):
yield 'Make', wf
elif isinstance(wf, dict) and isinstance(wf.get('nodes'), list) and 'connections' in wf:
yield 'n8n', wf
VERSION = re.compile(r'\s+v(\d+(?:\.\d+)?)$')
def proceso_y_version(nombre):
m = VERSION.search(nombre)
return VERSION.sub('', nombre).strip(), tuple(int(x) for x in m.group(1).split('.')) if m else (1,)
# Los mismos criterios del Día 79: última versión de cada proceso de Trigo Lento, sin sustituidos ni retirados.
SUSTITUIDOS = {'TL Formulario a hoja', 'TL Pedidos WhatsApp'}
RETIRADOS = {'TL Importación histórica', 'TL Lote de importación'}
leidos = []
for ruta in sorted(Path('trigo_lento').glob('dia_*/flujos/*/*.json'), key = lambda r: (int(r.parts[1][4:]), r.name)):
for plataforma, wf in leer_exportacion(ruta):
if wf['name'].startswith('TL ') and not re.search(r'fragmento|borrador', wf['name']):
proceso, version = proceso_y_version(wf['name'])
leidos.append({'proceso': proceso, 'version': version, 'plataforma': plataforma, 'dia': int(ruta.parts[1][4:]),
'nombre': wf['name'], 'wf': wf})
exportes = pd.DataFrame(leidos)
ultimas = exportes.sort_values(['version', 'dia']).groupby('proceso').tail(1)
activos = ultimas[~ultimas.proceso.isin(SUSTITUIDOS | RETIRADOS)].sort_values('dia').reset_index(drop = True)
# Credenciales que nombran los nodos de n8n y conexiones que nombran los módulos de Make:
referencias = []
for fila in activos.itertuples():
if fila.plataforma == 'n8n':
for n in fila.wf['nodes']:
for tipo, c in (n.get('credentials') or {}).items():
referencias.append({'flujo': fila.nombre, 'plataforma': 'n8n', 'credencial': c['name'], 'tipo_nodo': tipo})
else:
for m in modulos_make(fila.wf['flow']):
if '__IMTCONN__' in (m.get('parameters') or {}):
referencias.append({'flujo': fila.nombre, 'plataforma': 'Make', 'credencial': 'conexión ' + m['module'].split(':')[0],
'tipo_nodo': m['module']})
referencias = pd.DataFrame(referencias).drop_duplicates(['flujo', 'credencial'])
print(f'Flujos activos: {len(activos)} ({activos.plataforma.value_counts().to_dict()}) | referencias a credenciales: {len(referencias)} | '
f'nombres distintos: {referencias.credencial.nunique()}')
print(referencias.groupby('credencial').flujo.agg(['count', lambda s: ', '.join(x.replace('TL ', '') for x in s)])
.set_axis(['flujos', 'cuáles'], axis = 1).sort_values('flujos', ascending = False).to_string())
# Inventario completo (los datos de dueño, cuenta, alcance y fechas los declaró Valeria; las fechas son el Día en que se creó):
INVENTARIO = [ # id, nombre, sistema, tipo, dueño, cuenta, alcance concedido, creada (Día o fecha), caducidad propia en días, dónde se guarda
('C01', 'Clave de Claude (producción)', 'Anthropic', 'clave de API', 'Valeria', 'negocio', 'espacio Default, sin tope propio', 2, None, 'credencial de n8n y llavero de Make'),
('C02', 'Clave de Claude (evaluación)', 'Anthropic', 'clave de API', 'Valeria', 'negocio', 'misma clave que C01', 70, None, 'credencial de n8n'),
('C03', 'Clave de Voyage AI', 'Voyage AI', 'clave de API', 'Valeria', 'negocio', 'embeddings', 68, None, 'variable de entorno del indexador'),
('C04', 'Token de usuario del sistema (Cloud API)', 'Meta', 'token OAuth', 'Valeria', 'negocio', 'mensajes, gestión y business_management', 48, 60, 'credencial de n8n'),
('C05', 'Secreto de la aplicación de Meta', 'Meta', 'secreto de firma', 'Valeria', 'negocio', 'firma X-Hub-Signature-256', 48, None, 'variable de entorno de n8n'),
('C06', 'Token del bot de Slack', 'Slack', 'token OAuth', 'Valeria', 'negocio', '7 alcances', 50, None, 'credencial de n8n'),
('C07', 'Secreto de firma de Slack', 'Slack', 'secreto de firma', 'Valeria', 'negocio', 'firma X-Slack-Signature', 50, None, 'variable de entorno de n8n'),
('C08', 'URL del webhook entrante de Slack', 'Slack', 'URL de webhook', 'Valeria', 'negocio', 'publicar en #alertas', 75, None, 'credencial de n8n'),
('C09', 'Integración de Notion', 'Notion', 'token de integración', 'Valeria', 'personal', '14 páginas del espacio', 68, None, 'variable de entorno del indexador'),
('C10', 'OAuth de Gmail (mayoristas)', 'Google', 'token OAuth', 'Valeria', 'negocio', 'gmail.modify', 47, None, 'credencial de n8n'),
('C11', 'OAuth de Google Sheets', 'Google', 'token OAuth', 'Valeria', 'negocio', 'drive.file', 22, None, 'conexión de Make y credencial de n8n'),
('C12', 'Token de página de Meta (Facebook e Instagram)', 'Meta', 'token OAuth', 'Valeria', 'negocio', '4 permisos de página', 72, 60, 'credencial de n8n'),
('C13', 'Token de LinkedIn (página)', 'LinkedIn', 'token OAuth', 'Valeria', 'personal', '3 alcances', 72, 60, 'credencial de n8n'),
('C14', 'Clave de FacturaFácil', 'FacturaFácil', 'clave de API', 'Valeria', 'negocio', 'emitir, consultar y cancelar', 40, None, 'credencial de n8n'),
('C15', 'Token del servidor MCP del inventario', 'servidor propio', 'token de servicio', 'Joaquín', 'negocio', '3 herramientas', 62, None, 'variable de entorno (MCP_TOKEN_OBRADOR)'),
('C16', 'Clave de API de n8n', 'n8n', 'clave de API', 'Valeria', 'negocio', 'API pública completa', 42, None, 'gestor de contraseñas'),
('C17', 'N8N_ENCRYPTION_KEY', 'n8n', 'clave de cifrado', 'Valeria', 'negocio', 'cifra todas las credenciales', 34, None, '.env del servidor y gestor (con huella)'),
('C18', 'Contraseñas de Postgres (4 roles)', 'Postgres', 'contraseña', 'Valeria', 'negocio', 'n8n, n8n_lectura, n8n_lotes, n8n_registro', 34, None, '.env y credenciales de n8n'),
('C19', 'Llave SSH del servidor', 'servidor', 'llave SSH', 'Valeria', 'negocio', 'root', 34, None, 'portátil de Valeria'),
('C20', 'Clave de API de Make', 'Make', 'clave de API', 'Valeria', 'negocio', '4 alcances', 42, None, 'gestor de contraseñas'),
('C21', 'Auth Token de Twilio', 'Twilio', 'clave de API', 'Valeria', 'negocio', 'enviar y recibir mensajes', 16, None, 'credencial de n8n y conexión de Make'),
('C22', 'Clave del formulario web', 'formulario', 'secreto de firma', 'Valeria', 'negocio', 'autenticar el webhook', 11, None, 'credencial de n8n y conexión de Make'),
('C23', 'Contraseña del correo de avisos (SMTP)', 'correo', 'contraseña', 'Valeria', 'negocio', 'enviar avisos internos', 18, None, 'credencial de n8n'),
('C24', 'Cuenta del proveedor del servidor', 'proveedor del servidor', 'contraseña', 'Valeria', 'negocio', 'crear y borrar servidores', 34, None, 'gestor de contraseñas'),
('C25', 'Cuenta del registrador del dominio', 'dominio', 'contraseña', 'Don Ernesto', 'personal', 'DNS y renovación', date(2019, 8, 15), None, 'libreta de Don Ernesto'),
('C26', 'Token del repositorio de flujos', 'GitHub', 'clave de API', 'Valeria', 'negocio', 'contents, workflows, metadata', 42, 90, 'gestor de contraseñas'),
]
inventario = pd.DataFrame(INVENTARIO, columns = ['id', 'nombre', 'sistema', 'tipo', 'dueno', 'cuenta', 'alcance', 'creada', 'caducidad', 'guardado'])
inventario['creada'] = inventario.creada.map(lambda d: d if isinstance(d, date) else fecha_dia(d))
inventario['rotada'] = pd.NaT # ninguna credencial tiene una rotación registrada
inventario['pista'] = inventario.id.map({'C01': 'a7Qx', 'C02': 'a7Qx'}).fillna('') # últimos 4 caracteres que muestra la consola
# Unión: cada nombre de credencial de los JSON apunta a una fila del inventario:
MAPA = [(r'^(Claude|Anthropic)', 'C01'), (r'^Postgres', 'C18'), (r'^Twilio|conexión twilio', 'C21'), (r'^Gmail', 'C10'),
(r'^Hojas? de Trigo|conexión google-sheets', 'C11'), (r'^Meta TL token de página', 'C12'), (r'^Slack bot', 'C06'),
(r'^FacturaFácil', 'C14'), (r'Cloud API', 'C04'), (r'^LinkedIn', 'C13'), (r'^Correo de avisos', 'C23'),
(r'^Clave del formulario', 'C22'), (r'conexión http', 'C01'), (r'conexión gmail|conexión google-email', 'C10')]
def identificar(nombre, flujo):
ident = next((i for patron, i in MAPA if re.search(patron, nombre)), None)
return 'C02' if ident == 'C01' and 'Evaluación' in flujo else ident
referencias['id'] = [identificar(c, f) for c, f in zip(referencias.credencial, referencias.flujo)]
sin_fila = referencias[referencias.id.isna()]
print('Referencias sin fila en el inventario:', sorted(sin_fila.credencial.unique()) or 'ninguna')
usos = referencias.groupby('id').flujo.agg(lambda s: sorted(set(s)))
inventario['flujos'] = inventario.id.map(usos).map(lambda v: v if isinstance(v, list) else [])
inventario['n_flujos'] = inventario.flujos.str.len()
inventario['nombres_n8n'] = inventario.id.map(referencias[referencias.plataforma == 'n8n'].groupby('id').credencial.nunique()).fillna(0).astype(int)
print(inventario[['id', 'nombre', 'tipo', 'n_flujos', 'nombres_n8n', 'dueno', 'cuenta', 'creada', 'caducidad', 'guardado']]
.assign(creada = inventario.creada.map(lambda d: f'{d:%d/%m/%Y}'), caducidad = inventario.caducidad.map(lambda c: f'{int(c)} días' if pd.notna(c) else '')).to_string(index = False))
print(f'Credenciales: {len(inventario)} | en flujos: {int((inventario.n_flujos > 0).sum())} | de infraestructura y consolas: '
f'{int((inventario.n_flujos == 0).sum())} | en cuentas personales: {int((inventario.cuenta == "personal").sum())} | '
f'con dueño distinto de Valeria: {int((inventario.dueno != "Valeria").sum())}')
duplicadas = inventario[inventario.pista.ne('') & inventario.duplicated('pista', keep = False)]
print('Misma pista en la consola (misma clave):', ' = '.join(duplicadas.id), '->', ', '.join(f.replace('TL ', '') for f in duplicadas.flujos.sum()))
print(f'Flujos de n8n con credenciales de Postgres bajo {inventario.set_index("id").nombres_n8n["C18"]} nombres distintos; '
f'Claude bajo {inventario.set_index("id").nombres_n8n["C01"] + inventario.set_index("id").nombres_n8n["C02"]}')
inventario.drop(columns = ['flujos']).assign(flujos = inventario.flujos.str.join('; ')).to_csv(carpeta / 'inventario_credenciales.csv', index = False)
Flujos activos: 23 ({'n8n': 19, 'Make': 4}) | referencias a credenciales: 41 | nombres distintos: 24
flujos cuáles
credencial
Claude x-api-key TL 6 Facturación, Gmail mayoristas v1, Lote nocturno v1, Consulta de inventario v1, Informe del lunes v1, Evaluación del agente v1
Postgres TL 6 Facturación, Slack operaciones v1, Métricas de atención v1, Monitor de plantillas v1, Consulta de inventario v1, Alertas de flujos v1
conexión google-sheets 4 Formulario v2, Recordatorio de recogida v1, Brújula diario v1, Pastel con alergias v1
Postgres Trigo Lento 3 Pedidos WhatsApp n8n v2.1, WhatsApp secuencias v1, Orquestador v1
Postgres TL registro 2 Informe del lunes v1, Evaluación del agente v1
Slack bot TL 2 Informe del lunes v1, Evaluación del agente v1
Anthropic Trigo Lento 1 Clasificador IA v1
Anthropic TL nodo 1 Agente de pedidos v4
Gmail mayoristas TL (solo gmail.modify) 1 Gmail mayoristas v1
FacturaFácil x-api-key TL 1 Facturación
Correo de avisos 1 Pedidos WhatsApp n8n v2.1
Clave del formulario TL 1 Orquestador v1
Claude (x-api-key) 1 Pedidos WhatsApp n8n v2.1
Postgres TL (rol n8n_lotes) 1 Lote nocturno v1
Meta Cloud API TL (Bearer) 1 WhatsApp secuencias v1
Hojas de Trigo Lento 1 Pedidos WhatsApp n8n v2.1
Postgres TL solo lectura 1 Informe del lunes v1
Postgres TL evaluación (solo lectura) 1 Evaluación del agente v1
Postgres TL aprobaciones 1 Aprobaciones v2
Postgres TL solo publicaciones 1 Publicación multired v2
Twilio TL 1 Agente de pedidos v4
Twilio hacia n8n (Basic) 1 Pedidos WhatsApp n8n v2.1
conexión google-email 1 Brújula diario v1
conexión http 1 Pastel con alergias v1
Referencias sin fila en el inventario: ninguna
id nombre tipo n_flujos nombres_n8n dueno cuenta creada caducidad guardado
C01 Clave de Claude (producción) clave de API 9 4 Valeria negocio 02/02/2026 credencial de n8n y llavero de Make
C02 Clave de Claude (evaluación) clave de API 1 1 Valeria negocio 11/04/2026 credencial de n8n
C03 Clave de Voyage AI clave de API 0 0 Valeria negocio 09/04/2026 variable de entorno del indexador
C04 Token de usuario del sistema (Cloud API) token OAuth 1 1 Valeria negocio 20/03/2026 60 días credencial de n8n
C05 Secreto de la aplicación de Meta secreto de firma 0 0 Valeria negocio 20/03/2026 variable de entorno de n8n
C06 Token del bot de Slack token OAuth 2 1 Valeria negocio 22/03/2026 credencial de n8n
C07 Secreto de firma de Slack secreto de firma 0 0 Valeria negocio 22/03/2026 variable de entorno de n8n
C08 URL del webhook entrante de Slack URL de webhook 0 0 Valeria negocio 16/04/2026 credencial de n8n
C09 Integración de Notion token de integración 0 0 Valeria personal 09/04/2026 variable de entorno del indexador
C10 OAuth de Gmail (mayoristas) token OAuth 2 1 Valeria negocio 19/03/2026 credencial de n8n
C11 OAuth de Google Sheets token OAuth 5 1 Valeria negocio 22/02/2026 conexión de Make y credencial de n8n
C12 Token de página de Meta (Facebook e Instagram) token OAuth 0 0 Valeria negocio 13/04/2026 60 días credencial de n8n
C13 Token de LinkedIn (página) token OAuth 0 0 Valeria personal 13/04/2026 60 días credencial de n8n
C14 Clave de FacturaFácil clave de API 1 1 Valeria negocio 12/03/2026 credencial de n8n
C15 Token del servidor MCP del inventario token de servicio 0 0 Joaquín negocio 03/04/2026 variable de entorno (MCP_TOKEN_OBRADOR)
C16 Clave de API de n8n clave de API 0 0 Valeria negocio 14/03/2026 gestor de contraseñas
C17 N8N_ENCRYPTION_KEY clave de cifrado 0 0 Valeria negocio 06/03/2026 .env del servidor y gestor (con huella)
C18 Contraseñas de Postgres (4 roles) contraseña 14 8 Valeria negocio 06/03/2026 .env y credenciales de n8n
C19 Llave SSH del servidor llave SSH 0 0 Valeria negocio 06/03/2026 portátil de Valeria
C20 Clave de API de Make clave de API 0 0 Valeria negocio 14/03/2026 gestor de contraseñas
C21 Auth Token de Twilio clave de API 2 2 Valeria negocio 16/02/2026 credencial de n8n y conexión de Make
C22 Clave del formulario web secreto de firma 1 1 Valeria negocio 11/02/2026 credencial de n8n y conexión de Make
C23 Contraseña del correo de avisos (SMTP) contraseña 1 1 Valeria negocio 18/02/2026 credencial de n8n
C24 Cuenta del proveedor del servidor contraseña 0 0 Valeria negocio 06/03/2026 gestor de contraseñas
C25 Cuenta del registrador del dominio contraseña 0 0 Don Ernesto personal 15/08/2019 libreta de Don Ernesto
C26 Token del repositorio de flujos clave de API 0 0 Valeria negocio 14/03/2026 90 días gestor de contraseñas
Credenciales: 26 | en flujos: 11 | de infraestructura y consolas: 15 | en cuentas personales: 3 | con dueño distinto de Valeria: 2
Misma pista en la consola (misma clave): C01 = C02 -> Agente de pedidos v4, Clasificador IA v1, Consulta de inventario v1, Facturación, Gmail mayoristas v1, Informe del lunes v1, Lote nocturno v1, Pastel con alergias v1, Pedidos WhatsApp n8n v2.1, Evaluación del agente v1
Flujos de n8n con credenciales de Postgres bajo 8 nombres distintos; Claude bajo 5
El inventario tiene $26$ credenciales: $11$ se usan en flujos y las otras $15$ son de infraestructura y consolas, el tipo de llave que no aparece en ningún JSON y que, justamente por eso, nadie contaba. Los $23$ flujos hacen $41$ referencias bajo $24$ nombres distintos, y ahí está la primera lección del inventario: los nombres no son las llaves. Las contraseñas de Postgres viven bajo $8$ nombres de credencial de n8n (Postgres TL, Postgres Trigo Lento, Postgres TL solo lectura, Postgres TL registro…) que corresponden a $4$ roles de la base, y la clave de Claude bajo $5$ nombres (Claude x-api-key TL, Anthropic TL nodo, Anthropic Trigo Lento, Claude (x-api-key) y la del llavero de Make) que corresponden a una sola clave. La prueba no necesita el secreto: la consola muestra los últimos cuatro caracteres de cada clave, y la pista a7Qx es la misma en la fila C01 y en la fila C02. Diez flujos, incluida la evaluación del agente del Día 70, abren la misma puerta con la misma llave.
La Tabla 1 es el inventario completo, con las columnas que pide una auditoría y sin la única que no debe tener, el valor. Las fechas de creación son las del día de la serie en que cada llave nació (la de Claude, el Día 2; las de Meta, el Día 48; la del dominio, en 2019, mucho antes de la serie), y la columna de última rotación está vacía en las $26$ filas: ninguna credencial se ha cambiado desde que se creó.
Tabla 1. Inventario de credenciales de Trigo Lento al 21 de abril de 2026 (sin valores; los flujos se leen de las exportaciones y el resto lo declara Valeria).
| Id | Credencial | Sistema | Tipo | Flujos que la usan | Dueño | Cuenta | Alcance concedido | Creada | Última rotación | Caducidad | Dónde se guarda |
|---|---|---|---|---|---|---|---|---|---|---|---|
| C01 | Clave de Claude (producción) | Anthropic | clave de API | 9 | Valeria | negocio | espacio Default, sin tope propio | 02/02/2026 | nunca | ninguna | credencial de n8n y llavero de Make |
| C02 | Clave de Claude (evaluación) | Anthropic | clave de API | 1 | Valeria | negocio | misma clave que C01 (pista a7Qx) | 11/04/2026 | nunca | ninguna | credencial de n8n |
| C03 | Clave de Voyage AI | Voyage AI | clave de API | 0 | Valeria | negocio | embeddings | 09/04/2026 | nunca | ninguna | variable de entorno del indexador |
| C04 | Token de usuario del sistema (Cloud API) | Meta | token OAuth | 1 | Valeria | negocio | mensajes, gestión y business_management | 20/03/2026 | nunca | 60 días (19/05) | credencial de n8n |
| C05 | Secreto de la aplicación de Meta | Meta | secreto de firma | 0 | Valeria | negocio | firma X-Hub-Signature-256 | 20/03/2026 | nunca | ninguna | variable de entorno de n8n |
| C06 | Token del bot de Slack | Slack | token OAuth | 2 | Valeria | negocio | 7 alcances | 22/03/2026 | nunca | ninguna | credencial de n8n |
| C07 | Secreto de firma de Slack | Slack | secreto de firma | 0 | Valeria | negocio | firma X-Slack-Signature | 22/03/2026 | nunca | ninguna | variable de entorno de n8n |
| C08 | URL del webhook entrante de Slack | Slack | URL de webhook | 0 | Valeria | negocio | publicar en #alertas | 16/04/2026 | nunca | ninguna | credencial de n8n |
| C09 | Integración de Notion | Notion | token de integración | 0 | Valeria | personal | 14 páginas del espacio | 09/04/2026 | nunca | ninguna | variable de entorno del indexador |
| C10 | OAuth de Gmail (mayoristas) | token OAuth | 2 | Valeria | negocio | gmail.modify | 19/03/2026 | nunca | renovado por el proveedor | credencial de n8n | |
| C11 | OAuth de Google Sheets | token OAuth | 5 | Valeria | negocio | drive.file | 22/02/2026 | nunca | renovado por el proveedor | conexión de Make y credencial de n8n | |
| C12 | Token de página de Meta (Facebook e Instagram) | Meta | token OAuth | 0 | Valeria | negocio | 4 permisos de página | 13/04/2026 | nunca | 60 días (12/06) | credencial de n8n |
| C13 | Token de LinkedIn (página) | token OAuth | 0 | Valeria | personal | 3 alcances | 13/04/2026 | nunca | 60 días (12/06) | credencial de n8n | |
| C14 | Clave de FacturaFácil | FacturaFácil | clave de API | 1 | Valeria | negocio | emitir, consultar y cancelar | 12/03/2026 | nunca | ninguna | credencial de n8n |
| C15 | Token del servidor MCP del inventario | servidor propio | token de servicio | 0 | Joaquín | negocio | 3 herramientas | 03/04/2026 | nunca | ninguna | variable de entorno (MCP_TOKEN_OBRADOR) |
| C16 | Clave de API de n8n | n8n | clave de API | 0 | Valeria | negocio | API pública completa | 14/03/2026 | nunca | ninguna | gestor de contraseñas |
| C17 | N8N_ENCRYPTION_KEY | n8n | clave de cifrado | 0 | Valeria | negocio | cifra todas las credenciales | 06/03/2026 | nunca | no se rota | .env del servidor y gestor (con huella) |
| C18 | Contraseñas de Postgres (4 roles) | Postgres | contraseña | 14 | Valeria | negocio | n8n, n8n_lectura, n8n_lotes, n8n_registro | 06/03/2026 | nunca | ninguna | .env y credenciales de n8n |
| C19 | Llave SSH del servidor | servidor | llave SSH | 0 | Valeria | negocio | root | 06/03/2026 | nunca | ninguna | portátil de Valeria |
| C20 | Clave de API de Make | Make | clave de API | 0 | Valeria | negocio | 4 alcances | 14/03/2026 | nunca | ninguna | gestor de contraseñas |
| C21 | Auth Token de Twilio | Twilio | clave de API | 2 | Valeria | negocio | enviar y recibir mensajes | 16/02/2026 | nunca | ninguna | credencial de n8n y conexión de Make |
| C22 | Clave del formulario web | formulario | secreto de firma | 1 | Valeria | negocio | autenticar el webhook | 11/02/2026 | nunca | ninguna | credencial de n8n y conexión de Make |
| C23 | Contraseña del correo de avisos (SMTP) | correo | contraseña | 1 | Valeria | negocio | enviar avisos internos | 18/02/2026 | nunca | ninguna | credencial de n8n |
| C24 | Cuenta del proveedor del servidor | proveedor del servidor | contraseña | 0 | Valeria | negocio | crear y borrar servidores | 06/03/2026 | nunca | ninguna | gestor de contraseñas |
| C25 | Cuenta del registrador del dominio | dominio | contraseña | 0 | Don Ernesto | personal | DNS y renovación | 15/08/2019 | nunca | ninguna | libreta de Don Ernesto |
| C26 | Token del repositorio de flujos | GitHub | clave de API | 0 | Valeria | negocio | contents, workflows, metadata | 14/03/2026 | nunca | 90 días (12/06) | gestor de contraseñas |
Tres filas tienen la columna “cuenta” en personal: la integración de Notion (C09), el token de LinkedIn (C13), que depende de que Valeria siga siendo administradora de la página con su perfil, y el dominio (C25). Dos tienen un dueño que no es Valeria: el token del servidor MCP, de Joaquín, el panadero que lleva el inventario desde el Día 63, y el dominio, de Don Ernesto. El resto es de Valeria, que es la misma concentración que el factor autobús de ayer midió en los flujos: si una persona concentra las llaves, el negocio tiene un punto único de fallo con nombre y apellido.
La Figura 1 dibuja ese llavero como lo ve Don Ernesto cada noche: cada llave con su etiqueta, agrupadas por la puerta que abren, con la duplicada y las personales a la vista.
Figura 1. El llavero del obrador: las 26 credenciales de Trigo Lento como llaves con etiqueta (dueño, puerta que abren y fecha del último cambio), agrupadas en flujos, infraestructura y consolas; la llave de Claude aparece dos veces con la misma pista y tres llaves están a nombre de una persona, no del negocio.
Rotación y caducidad
Rotar una credencial es sustituirla por otra y revocar la vieja. Se hace por dos razones que conviene separar. La primera es la caducidad que impone el proveedor: un token de acceso de LinkedIn dura $60$ días (y su token de actualización, $365$, según la documentación de Microsoft Learn), un token de usuario del sistema de Meta se genera con caducidad de $60$ días o sin caducidad, y un token de acceso personal de GitHub de grano fino lleva la fecha que se le puso al crearlo. Si nadie los renueva antes, el flujo deja de funcionar un día cualquiera, y el Día 81 empieza precisamente con esa escena. La segunda razón es la política propia: aunque una clave de API no caduque nunca, Trigo Lento decide cambiarla cada cierto tiempo para acotar el daño de una filtración que no se haya detectado. El Día 17 dio la fórmula: si un secreto se filtra en un momento uniforme dentro de un periodo de rotación de $T$ días y nadie lo nota, la exposición esperada es
$$ E[\text{exposición}] = \frac{T}{2} $$
- $T$ es el intervalo de rotación en días: $90$ para las claves de API, $180$ para los secretos de firma y las contraseñas, $365$ para la llave SSH.
- La filtración ocurre en un instante uniforme dentro del intervalo, así que en promedio queda la mitad del intervalo por delante antes de que la rotación la anule.
- Con $T = 90$, una clave filtrada sin detección sigue abriendo puertas $45$ días; con $T = 2,441$, los días que lleva la contraseña del dominio, la cifra deja de tener sentido como estimación y pasa a ser una certeza de que, si alguna vez se filtró, sigue valiendo.
- La clave de cifrado de n8n es la excepción: rotarla exige exportar las credenciales descifradas y volverlas a importar con la clave nueva (Día 35), así que no tiene intervalo y solo se cambia ante un incidente.
El bloque aplica la política por tipo, toma como “último cambio” la fecha de creación cuando no hay rotación registrada (es decir, en las $26$ filas), calcula los días transcurridos y la fecha en que cada credencial vence, y clasifica cada una según lo que pasa antes: la revisión mensual del 4 de mayo, los $30$ días de alerta, la revisión trimestral del 6 de julio:
# Estado de rotación frente a la política por tipo y alertas de caducidad a 30 días en Python:
POLITICA = {'clave de API': 90, 'token de servicio': 90, 'token de integración': 90, 'secreto de firma': 180, 'URL de webhook': 180,
'contraseña': 180, 'llave SSH': 365, 'clave de cifrado': None, 'token OAuth': None} # None: caducidad propia o del proveedor
inventario['ultimo_cambio'] = inventario.rotada.fillna(pd.Series(pd.to_datetime(inventario.creada))).dt.date
inventario['dias'] = (hoy - inventario.ultimo_cambio).map(lambda d: d.days)
inventario['plazo'] = [int(c) if pd.notna(c) else POLITICA[t] for t, c in zip(inventario.tipo, inventario.caducidad)]
inventario['vence'] = [u + timedelta(days = int(p)) if pd.notna(p) else None for u, p in zip(inventario.ultimo_cambio, inventario.plazo)]
def estado(v, tipo):
if v is None:
return 'sin rotación programada' if tipo == 'clave de cifrado' else 'renovado por el proveedor'
if v < hoy:
return 'vencida'
if v <= hoy + timedelta(days = 30):
return 'por vencer (30 días)'
if v <= REVISION_TRIMESTRAL:
return 'antes de la revisión trimestral'
return 'vigente'
inventario['estado'] = [estado(v, t) for v, t in zip(inventario.vence, inventario.tipo)]
inventario['ultimo'] = inventario.ultimo_cambio.map(lambda d: f'{d:%d/%m/%Y}')
inventario['vence_txt'] = inventario.vence.map(lambda d: f'{d:%d/%m/%Y}' if d else '')
orden_estado = ['vencida', 'por vencer (30 días)', 'antes de la revisión trimestral', 'vigente', 'renovado por el proveedor', 'sin rotación programada']
tabla = inventario.sort_values(['estado', 'dias'], key = lambda s: s.map(orden_estado.index) if s.name == 'estado' else -s)
print(tabla[['id', 'nombre', 'tipo', 'ultimo', 'dias', 'plazo', 'vence_txt', 'estado']].assign(plazo = tabla.plazo.map(lambda p: f'{int(p)}' if pd.notna(p) else '')).to_string(index = False))
print('Por estado:', inventario.estado.value_counts().reindex(orden_estado).fillna(0).astype(int).to_dict())
print(f'Rotaciones registradas: {int(inventario.rotada.notna().sum())} de {len(inventario)} | mediana de días desde el último cambio: '
f'{inventario.dias.median():.0f} | máximo: {inventario.dias.max()} ({inventario.loc[inventario.dias.idxmax(), "nombre"]})')
urgentes = inventario[inventario.estado.isin(['vencida', 'por vencer (30 días)'])]
print('Atender antes del', f'{REVISION_MENSUAL:%d/%m}:', '; '.join(f'{i} {n} ({v})' for i, n, v in zip(urgentes.id, urgentes.nombre, urgentes.vence_txt)))
COLOR_TIPO = {'clave de API': '#2a78d6', 'token OAuth': '#7b4fc9', 'secreto de firma': '#eb6834', 'contraseña': '#c0392b',
'llave SSH': '#6b5b3e', 'clave de cifrado': '#1f8a5b', 'token de integración': '#b04bb3', 'token de servicio': '#b04bb3',
'URL de webhook': '#8a8a8a'}
dib = inventario.sort_values('dias', ascending = True).reset_index(drop = True)
fig, ax = plt.subplots(figsize = (11, 8.6))
ys = np.arange(len(dib))
ax.barh(ys, dib.dias.clip(upper = 400), color = [COLOR_TIPO[t] for t in dib.tipo], height = 0.62)
for y, f in zip(ys, dib.itertuples()):
if pd.notna(f.plazo):
ax.plot([f.plazo, f.plazo], [y - 0.38, y + 0.38], color = '#c0392b' if f.dias > f.plazo else '#34332f', lw = 2.2)
etiqueta = f'{f.dias} d' + (f' (>{f.dias - 400} recortado)' if f.dias > 400 else '') + (f', caduca el {f.vence:%d/%m}' if pd.notna(f.caducidad) else '')
ax.text(max(min(f.dias, 400), f.plazo if pd.notna(f.plazo) else 0) + 4, y, etiqueta, va = 'center', fontsize = 8.6, color = '#34332f')
ax.set_yticks(ys, [f'{i} {n[:38]}' for i, n in zip(dib.id, dib.nombre)], fontsize = 8.8)
ax.axvline(30, color = '#c9c7c1', ls = ':', lw = 1)
ax.set_xlim(0, 470)
ax.set_xlabel('Días desde la creación o la última rotación (barra); marca vertical: plazo de la política o caducidad propia')
ax.set_title('26 credenciales de Trigo Lento: ninguna rotada; una vencida y tres por vencer en 30 días', fontsize = 12.5)
ax.legend(handles = [Patch(color = c, label = t) for t, c in COLOR_TIPO.items()], frameon = False, fontsize = 8.5, loc = 'lower right', ncol = 2)
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
id nombre tipo ultimo dias plazo vence_txt estado
C25 Cuenta del registrador del dominio contraseña 15/08/2019 2441 180 11/02/2020 vencida
C01 Clave de Claude (producción) clave de API 02/02/2026 78 90 03/05/2026 por vencer (30 días)
C21 Auth Token de Twilio clave de API 16/02/2026 64 90 17/05/2026 por vencer (30 días)
C04 Token de usuario del sistema (Cloud API) token OAuth 20/03/2026 32 60 19/05/2026 por vencer (30 días)
C14 Clave de FacturaFácil clave de API 12/03/2026 40 90 10/06/2026 antes de la revisión trimestral
C16 Clave de API de n8n clave de API 14/03/2026 38 90 12/06/2026 antes de la revisión trimestral
C20 Clave de API de Make clave de API 14/03/2026 38 90 12/06/2026 antes de la revisión trimestral
C26 Token del repositorio de flujos clave de API 14/03/2026 38 90 12/06/2026 antes de la revisión trimestral
C15 Token del servidor MCP del inventario token de servicio 03/04/2026 18 90 02/07/2026 antes de la revisión trimestral
C12 Token de página de Meta (Facebook e Instagram) token OAuth 13/04/2026 8 60 12/06/2026 antes de la revisión trimestral
C13 Token de LinkedIn (página) token OAuth 13/04/2026 8 60 12/06/2026 antes de la revisión trimestral
C22 Clave del formulario web secreto de firma 11/02/2026 69 180 10/08/2026 vigente
C23 Contraseña del correo de avisos (SMTP) contraseña 18/02/2026 62 180 17/08/2026 vigente
C18 Contraseñas de Postgres (4 roles) contraseña 06/03/2026 46 180 02/09/2026 vigente
C19 Llave SSH del servidor llave SSH 06/03/2026 46 365 06/03/2027 vigente
C24 Cuenta del proveedor del servidor contraseña 06/03/2026 46 180 02/09/2026 vigente
C05 Secreto de la aplicación de Meta secreto de firma 20/03/2026 32 180 16/09/2026 vigente
C07 Secreto de firma de Slack secreto de firma 22/03/2026 30 180 18/09/2026 vigente
C03 Clave de Voyage AI clave de API 09/04/2026 12 90 08/07/2026 vigente
C09 Integración de Notion token de integración 09/04/2026 12 90 08/07/2026 vigente
C02 Clave de Claude (evaluación) clave de API 11/04/2026 10 90 10/07/2026 vigente
C08 URL del webhook entrante de Slack URL de webhook 16/04/2026 5 180 13/10/2026 vigente
C11 OAuth de Google Sheets token OAuth 22/02/2026 58 renovado por el proveedor
C10 OAuth de Gmail (mayoristas) token OAuth 19/03/2026 33 renovado por el proveedor
C06 Token del bot de Slack token OAuth 22/03/2026 30 renovado por el proveedor
C17 N8N_ENCRYPTION_KEY clave de cifrado 06/03/2026 46 sin rotación programada
Por estado: {'vencida': 1, 'por vencer (30 días)': 3, 'antes de la revisión trimestral': 7, 'vigente': 11, 'renovado por el proveedor': 3, 'sin rotación programada': 1}
Rotaciones registradas: 0 de 26 | mediana de días desde el último cambio: 38 | máximo: 2441 (Cuenta del registrador del dominio)
Atender antes del 04/05: C01 Clave de Claude (producción) (03/05/2026); C04 Token de usuario del sistema (Cloud API) (19/05/2026); C21 Auth Token de Twilio (17/05/2026); C25 Cuenta del registrador del dominio (11/02/2020)

Cero rotaciones de $26$, una mediana de $38$ días desde el último cambio y un máximo de $2,441$ días, los que lleva la contraseña del dominio. Por estado: $1$ vencida (el dominio, cuya rotación de $180$ días venció el 11 de febrero de 2020), $3$ por vencer en $30$ días (la clave de Claude de producción el 3 de mayo, el Auth Token de Twilio el 17 de mayo y el token de la Cloud API de Meta el 19 de mayo), $7$ antes de la revisión trimestral (FacturaFácil, n8n, Make y el repositorio en junio, los tokens de página de Meta y de LinkedIn el 12 de junio, el servidor MCP el 2 de julio), $11$ vigentes, $3$ que renueva el proveedor (los OAuth de Google y el bot de Slack, que no caducan mientras la aplicación siga autorizada) y $1$ sin rotación programada (la clave de cifrado).
De las tres por vencer, una es trivial y dos no. La de Twilio es trivial porque el proveedor se retiró ayer como respaldo: no hay que rotarla, hay que revocarla, y el hecho de que siga viva en $2$ credenciales de n8n y una conexión de Make es un hallazgo en sí mismo. La de Claude es la que se va a rotar hoy de todos modos, por la duplicidad. La del token de la Cloud API es la peligrosa: caduca el 19 de mayo, un martes, nadie tiene un recordatorio, y cuando caduque los $468$ encargos del mes dejan de entrar por WhatsApp hasta que alguien genere otro. Valeria lo apunta como el primer hallazgo de la lista, y el Día 81 lo convierte en el ensayo de mesa.
La figura del bloque ordena las $26$ credenciales por días desde el último cambio, colorea por tipo y marca con una raya el plazo de cada una (el de la política o la caducidad propia). La barra del dominio está recortada en $400$ días para que las demás se vean; en escala real sería seis veces más larga que la figura.
Alcances y mínimo privilegio
El Día 17 definió el mínimo privilegio con una matriz de permisos y midió un exceso de $29$ niveles en los tres alcances de Google de entonces. Hoy la medida es la misma idea con una herramienta más simple: conjuntos. Para cada credencial se escriben dos: $C$, lo que tiene concedido (leído de la pantalla de la consola o de la configuración de la credencial), y $N$, lo que el flujo necesita para hacer su trabajo (leído del flujo). Entonces:
$$ \text{sobrantes} = C \setminus N, \qquad \text{faltantes} = N \setminus C, \qquad \text{índice de mínimo privilegio} = \frac{|C \cap N|}{|C|} $$
- $C \setminus N$ son los permisos que la credencial tiene y el flujo no usa: cada uno es una puerta abierta de más que no se notaría cerrar.
- $N \setminus C$ son los permisos que el flujo necesita y la credencial no tiene; cuando aparecen junto a sobrantes es que la credencial tiene el papel equivocado (escritura donde hace falta lectura,
rootdonde hace falta un usuario consudo). - El índice vale $1$ cuando la credencial tiene exactamente lo que usa y baja hacia $0$ cuanto más sobra; es una proporción, no un riesgo, porque no todos los permisos pesan igual (ver $12$ páginas de Notion es más grave que tener
users:readen Slack). - La evidencia es la diferencia misma: el informe no dice “Gmail tiene demasiados permisos”, dice “sobran
enviar,etiquetarymover a la papelera”.
Dos casos merecen una nota antes del bloque. En Gmail, el Día 17 ya explicó que no existe un alcance que solo cree borradores: gmail.compose crea borradores y también envía, y el control de no enviar vive en el flujo. Por eso $N$ se escribe como {leer, crear borradores} y el alcance mínimo real es gmail.readonly más gmail.compose; lo que sobra con gmail.modify son las etiquetas y la papelera, y la capacidad de enviar queda como un riesgo aceptado y documentado. En Claude, la consola no tiene alcances, pero sí espacios de trabajo con límite de gasto mensual propio y claves asignadas a cada uno, según la documentación de la plataforma revisada en octubre de 2026: tener la clave en el espacio predeterminado sin tope propio y usarla también en evaluación son dos “permisos” de más, y el espacio produccion con tope de $10.50$ USD (el rubro del Día 77) es un “permiso” que falta. El bloque calcula las diferencias para las $14$ credenciales que tienen algo que comparar:
# Alcances concedidos frente a necesarios como diferencia de conjuntos por credencial en Python:
PAGINAS_NOTION = ['Base de conocimiento', 'Calendario editorial', 'Políticas', 'Fichas de producto', 'Avisos', 'Preguntas frecuentes',
'Proveedores', 'Horarios del personal', 'Nómina', 'Facturas 2025', 'Contratos', 'Actas de reuniones',
'Ideas de temporada', 'Mantenimiento del horno']
ALCANCES = { # id: (concedidos, necesarios)
'C01': ({'llamar a la API', 'espacio Default sin tope propio', 'uso en producción', 'uso en evaluación'},
{'llamar a la API', 'espacio produccion con tope de 10.50 USD', 'uso en producción'}),
'C04': ({'whatsapp_business_messaging', 'whatsapp_business_management', 'business_management'},
{'whatsapp_business_messaging', 'whatsapp_business_management'}),
'C06': ({'chat:write', 'chat:write.public', 'channels:read', 'channels:history', 'users:read', 'files:write', 'commands'},
{'chat:write', 'channels:read', 'channels:history', 'commands'}),
'C09': (set(PAGINAS_NOTION), {'Base de conocimiento', 'Calendario editorial'}),
'C10': ({'leer', 'crear borradores', 'enviar', 'etiquetar', 'mover a la papelera'}, {'leer', 'crear borradores'}),
'C11': ({'drive.file'}, {'drive.file'}),
'C12': ({'pages_manage_posts', 'instagram_content_publish', 'pages_read_engagement', 'pages_manage_metadata'},
{'pages_manage_posts', 'instagram_content_publish', 'pages_read_engagement'}),
'C13': ({'w_organization_social', 'r_organization_social', 'r_organization_admin'}, {'w_organization_social'}),
'C14': ({'emitir', 'consultar', 'cancelar'}, {'emitir', 'consultar'}),
'C15': ({'consultar_existencias', 'consultar_consumo', 'registrar_movimiento'}, {'consultar_existencias', 'consultar_consumo'}),
'C18': ({f'escritura en {f}' for f in ['Facturación', 'Slack operaciones', 'Alertas de flujos', 'Métricas de atención', 'Monitor de plantillas', 'Consulta de inventario']},
{'escritura en Facturación', 'escritura en Slack operaciones', 'lectura en Alertas de flujos', 'lectura en Métricas de atención',
'lectura en Monitor de plantillas', 'lectura en Consulta de inventario'}),
'C19': ({'root'}, {'usuario con sudo'}),
'C20': ({'scenarios:read', 'scenarios:write', 'connections:read', 'teams:write'}, {'scenarios:read'}),
'C26': ({'contents:write', 'workflows:write', 'metadata:read'}, {'contents:write', 'metadata:read'}),
}
filas = []
for i, (concedidos, necesarios) in ALCANCES.items():
sobran, faltan = concedidos - necesarios, necesarios - concedidos
filas.append({'id': i, 'credencial': inventario.set_index('id').nombre[i][:34], 'concedidos': len(concedidos), 'necesarios': len(necesarios),
'sobran': len(sobran), 'faltan': len(faltan), 'indice': len(concedidos & necesarios) / len(concedidos),
'detalle': ', '.join(sorted(sobran))[:60]})
alcances = pd.DataFrame(filas).sort_values(['sobran', 'id'], ascending = [False, True])
print(alcances.to_string(index = False, formatters = {'indice': '{:.2f}'.format}))
print(f'Credenciales con alcances revisables: {len(alcances)} | permisos sobrantes: {alcances.sobran.sum()} | faltantes (cambios de rol): '
f'{alcances.faltan.sum()} | con mínimo privilegio exacto: {int((alcances.sobran == 0).sum())} | índice medio: {alcances.indice.mean():.2f}')
print('Páginas de Notion que la integración ve sin necesitarlas:', ', '.join(sorted(ALCANCES['C09'][0] - ALCANCES['C09'][1])))
id credencial concedidos necesarios sobran faltan indice detalle
C09 Integración de Notion 14 2 12 0 0.14 Actas de reuniones, Avisos, Contratos, Facturas 2025, Fichas
C18 Contraseñas de Postgres (4 roles) 6 6 4 4 0.33 escritura en Alertas de flujos, escritura en Consulta de inv
C06 Token del bot de Slack 7 4 3 0 0.57 chat:write.public, files:write, users:read
C10 OAuth de Gmail (mayoristas) 5 2 3 0 0.40 enviar, etiquetar, mover a la papelera
C20 Clave de API de Make 4 1 3 0 0.25 connections:read, scenarios:write, teams:write
C01 Clave de Claude (producción) 4 3 2 1 0.50 espacio Default sin tope propio, uso en evaluación
C13 Token de LinkedIn (página) 3 1 2 0 0.33 r_organization_admin, r_organization_social
C04 Token de usuario del sistema (Clou 3 2 1 0 0.67 business_management
C12 Token de página de Meta (Facebook 4 3 1 0 0.75 pages_manage_metadata
C14 Clave de FacturaFácil 3 2 1 0 0.67 cancelar
C15 Token del servidor MCP del inventa 3 2 1 0 0.67 registrar_movimiento
C19 Llave SSH del servidor 1 1 1 1 0.00 root
C26 Token del repositorio de flujos 3 2 1 0 0.67 workflows:write
C11 OAuth de Google Sheets 1 1 0 0 1.00
Credenciales con alcances revisables: 14 | permisos sobrantes: 35 | faltantes (cambios de rol): 6 | con mínimo privilegio exacto: 1 | índice medio: 0.50
Páginas de Notion que la integración ve sin necesitarlas: Actas de reuniones, Avisos, Contratos, Facturas 2025, Fichas de producto, Horarios del personal, Ideas de temporada, Mantenimiento del horno, Nómina, Políticas, Preguntas frecuentes, Proveedores
$35$ permisos sobrantes en $14$ credenciales, $6$ faltantes que señalan cambios de rol, una sola credencial con el mínimo exacto (Google Sheets con drive.file, la corrección del Día 17 que sí se hizo) y un índice medio de $0.50$: la mitad de lo concedido no se usa. La integración de Notion se lleva $12$ de los $35$: necesita dos bases (la de conocimiento y el calendario editorial) y ve catorce páginas, entre ellas “Nómina”, “Contratos” y “Horarios del personal”, que son datos personales del equipo y no tienen por qué pasar por un indexador. Postgres TL, el rol con escritura, se usa en seis flujos de los que cuatro solo leen (las alertas, las métricas, el monitor de plantillas y la consulta de inventario); la corrección es apuntarlos al rol n8n_lectura que ya existe desde el Día 60. El bot de Slack tiene files:write, users:read y chat:write.public, que ningún flujo usa, y la clave de API de Make tiene escritura de escenarios y de equipos cuando el único uso es exportar blueprints (Día 42).
La Figura 2 convierte esas diferencias de conjuntos en pasillos con puertas: las verdes se quedan abiertas porque alguien las cruza; las amarillas se cierran sin que nada deje de funcionar.
Figura 2. Abrir solo las puertas necesarias: cinco credenciales como pasillos; cada puerta verde es un permiso necesario y cada puerta amarilla, uno concedido de más. En total, 35 puertas abiertas de más en 14 llaves, 12 de ellas páginas de Notion que la integración no necesita ver.
Dueños, cuentas y doble factor
Las credenciales abren puertas a las máquinas; las cuentas humanas abren las consolas donde se crean y se revocan esas credenciales, así que una cuenta humana vale más que cualquier llave que haya salido de ella. La auditoría revisa cinco controles por consola, tomados del control 5 y del control 6 de CIS Controls v8.1 y de la publicación NIST SP 800-63B-4 (julio de 2025), que clasifica los autenticadores por su resistencia:
- Cuenta del negocio: la consola está a nombre de Trigo Lento (correo del negocio, datos fiscales del negocio), no de una persona que un día puede irse o perder el acceso.
- Doble factor activo: entrar exige la contraseña y algo más.
- Segundo factor fuerte: una aplicación de códigos o una llave física valen $1$; un código por SMS vale $0.5$, porque NIST lo considera el autenticador más débil de los aceptados (es vulnerable al cambio de tarjeta SIM y al desvío de mensajes); ninguno vale $0$.
- Códigos de recuperación guardados: si el teléfono se pierde, hay forma de entrar sin llamar al soporte del proveedor.
- Segundo administrador: otra persona del negocio puede entrar si la primera no está.
El bloque construye la matriz con lo que Valeria comprobó consola por consola esta mañana y la dibuja como mapa de calor:
# Matriz de controles de las cuentas humanas de las consolas y mapa de calor en Python:
CUENTAS = [ # consola, titular, cuenta del negocio, doble factor, tipo de segundo factor, códigos de recuperación guardados, segundo administrador
('Meta Business Suite', 'Valeria', 1, 1, 'aplicación', 1, 1),
('Consola de Claude', 'Valeria', 1, 1, 'aplicación', 1, 0),
('Slack', 'Valeria', 1, 1, 'aplicación', 0, 1),
('Notion', 'Valeria', 0, 0, 'ninguno', 0, 0),
('Google (cuenta técnica)', 'Valeria', 1, 1, 'llave física', 1, 0),
('Proveedor del servidor', 'Valeria', 1, 1, 'SMS', 0, 0),
('Registrador del dominio', 'Don Ernesto', 0, 0, 'ninguno', 0, 0),
('GitHub', 'Valeria', 1, 1, 'aplicación', 1, 0),
('Make', 'Valeria', 1, 1, 'aplicación', 0, 0),
('FacturaFácil', 'Valeria', 1, 1, 'SMS', 0, 0),
('Voyage AI', 'Valeria', 1, 0, 'ninguno', 0, 0),
('LinkedIn (administrador de la página)', 'Valeria', 0, 1, 'SMS', 0, 0),
]
cuentas = pd.DataFrame(CUENTAS, columns = ['consola', 'titular', 'negocio', 'doble_factor', 'tipo', 'recuperacion', 'segundo_admin'])
VALOR_TIPO = {'llave física': 1.0, 'aplicación': 1.0, 'SMS': 0.5, 'ninguno': 0.0} # NIST 800-63B-4: el SMS es el factor más débil
cuentas['factor_fuerte'] = cuentas.tipo.map(VALOR_TIPO)
CONTROLES = ['negocio', 'doble_factor', 'factor_fuerte', 'recuperacion', 'segundo_admin']
cuentas['puntos'] = cuentas[CONTROLES].sum(axis = 1)
print(cuentas[['consola', 'titular', 'negocio', 'doble_factor', 'tipo', 'recuperacion', 'segundo_admin', 'puntos']].to_string(index = False))
print('Cumplimiento por control:', {c: f'{cuentas[c].sum():.1f} de {len(cuentas)}' for c in CONTROLES})
print('Segundo factor por tipo:', cuentas.tipo.value_counts().to_dict(), '| consolas con 5 de 5:', int((cuentas.puntos == 5).sum()),
'| con 2 o menos:', ', '.join(cuentas.consola[cuentas.puntos <= 2]))
print(f'Puntuación media: {cuentas.puntos.mean():.2f} de 5 | si Valeria no estuviera, consolas con otra persona que pueda entrar: '
f'{int(cuentas.segundo_admin.sum())} de {len(cuentas)}')
matriz = cuentas[CONTROLES].to_numpy()
ETIQ = ['Cuenta del\nnegocio', 'Doble factor\nactivo', 'Segundo factor\nfuerte', 'Códigos de\nrecuperación', 'Segundo\nadministrador']
fig, ax = plt.subplots(figsize = (9.5, 6.8))
ax.imshow(matriz, cmap = plt.cm.RdYlGn, vmin = -0.15, vmax = 1.15, aspect = 'auto')
for i in range(len(cuentas)):
for j, c in enumerate(CONTROLES):
v = matriz[i, j]
texto = cuentas.tipo[i] if c == 'factor_fuerte' else ('sí' if v == 1 else 'no')
ax.text(j, i, texto, ha = 'center', va = 'center', fontsize = 9, color = '#34332f')
ax.set_xticks(range(len(CONTROLES)), ETIQ, fontsize = 9.5)
ax.set_yticks(range(len(cuentas)), [f'{c} ({p:.1f})' for c, p in zip(cuentas.consola, cuentas.puntos)], fontsize = 9.5)
ax.set_xticks(np.arange(-0.5, len(CONTROLES)), minor = True)
ax.set_yticks(np.arange(-0.5, len(cuentas)), minor = True)
ax.grid(which = 'minor', color = 'white', lw = 2)
ax.tick_params(which = 'minor', length = 0)
ax.set_title('Controles de las 12 consolas de Trigo Lento (puntos de 5 entre paréntesis)', fontsize = 12.5)
plt.tight_layout()
plt.show()
consola titular negocio doble_factor tipo recuperacion segundo_admin puntos
Meta Business Suite Valeria 1 1 aplicación 1 1 5.0
Consola de Claude Valeria 1 1 aplicación 1 0 4.0
Slack Valeria 1 1 aplicación 0 1 4.0
Notion Valeria 0 0 ninguno 0 0 0.0
Google (cuenta técnica) Valeria 1 1 llave física 1 0 4.0
Proveedor del servidor Valeria 1 1 SMS 0 0 2.5
Registrador del dominio Don Ernesto 0 0 ninguno 0 0 0.0
GitHub Valeria 1 1 aplicación 1 0 4.0
Make Valeria 1 1 aplicación 0 0 3.0
FacturaFácil Valeria 1 1 SMS 0 0 2.5
Voyage AI Valeria 1 0 ninguno 0 0 1.0
LinkedIn (administrador de la página) Valeria 0 1 SMS 0 0 1.5
Cumplimiento por control: {'negocio': '9.0 de 12', 'doble_factor': '9.0 de 12', 'factor_fuerte': '7.5 de 12', 'recuperacion': '4.0 de 12', 'segundo_admin': '2.0 de 12'}
Segundo factor por tipo: {'aplicación': 5, 'ninguno': 3, 'SMS': 3, 'llave física': 1} | consolas con 5 de 5: 1 | con 2 o menos: Notion, Registrador del dominio, Voyage AI, LinkedIn (administrador de la página)
Puntuación media: 2.62 de 5 | si Valeria no estuviera, consolas con otra persona que pueda entrar: 2 de 12

De las $12$ consolas, $9$ están a nombre del negocio (Notion, el dominio y LinkedIn no), $9$ tienen doble factor (Notion, el dominio y Voyage AI no), el factor es fuerte en $7.5$ de $12$ (tres consolas usan SMS: el proveedor del servidor, FacturaFácil y LinkedIn), los códigos de recuperación están guardados en $4$ y hay segundo administrador en $2$ (Meta Business Suite y Slack, donde Don Ernesto es propietario). La única consola con $5$ de $5$ es Meta Business Suite, la que más duele perder; las cuatro con $2$ puntos o menos son Notion, el registrador del dominio, Voyage AI y LinkedIn. La media es de $2.62$ sobre $5$, y la última línea de la salida es la que Don Ernesto subraya: si Valeria no estuviera, otra persona del negocio podría entrar en $2$ de las $12$ consolas.
El dominio merece un párrafo propio. La cuenta del registrador está a nombre de Don Ernesto, con un correo personal, sin doble factor y con una contraseña de agosto de 2019 que él mismo apuntó en una libreta. Quien controle esa cuenta controla el DNS, y quien controle el DNS puede apuntar n8n.trigolento.example a otro servidor y recibir todos los webhooks de Meta, o cambiar los registros del correo del negocio. Es la llave que abre todas las demás, y es la peor protegida. Don Ernesto lo entiende con su propio ejemplo: “Es como si la llave de la calle estuviera colgada en la puerta.”
Hallazgos y severidad
Un hallazgo que no tiene fecha es una observación. Para que se cierre, cada uno lleva identificador, descripción, evidencia (el bloque o la tabla donde se ve), dueño, probabilidad e impacto en escala de $1$ a $5$, y de esos dos últimos salen la severidad y el plazo. La fórmula es la del Día 6 y la del Día 17:
$$ R = P \times I, \qquad \text{severidad} = \begin{cases} \text{crítica} & R \geq 15 \ \text{alta} & 9 \leq R < 15 \ \text{media} & 4 \leq R < 9 \ \text{baja} & R < 4 \end{cases} $$
- $P$ es la probabilidad de que el riesgo se materialice en los próximos doce meses, de $1$ (remota) a $5$ (casi segura); una caducidad con fecha conocida y sin recordatorio es un $5$, porque no es un riesgo sino una cita.
- $I$ es el impacto en clientes, dinero y datos, de $1$ (menor) a $5$ (crítico); perder el dominio o filtrar la nómina es un $5$, un alcance sobrante del bot de Slack es un $2$.
- Los umbrales de $R$ fijan la severidad, y la severidad fija el plazo de corrección: $24$ horas para lo crítico, $7$ días para lo alto, $30$ para lo medio y $90$ para lo bajo. La fecha límite se calcula, no se negocia.
- Dos hallazgos con el mismo $R$ pueden merecer distinto cuidado: como dijo el Día 6, los de impacto $5$ necesitan controles a la altura del impacto aunque su probabilidad sea baja.
El bloque registra los catorce hallazgos de la mañana, calcula severidad y fecha límite y los ordena:
# Hallazgos con probabilidad, impacto, severidad y plazo de corrección calculado en Python:
HALLAZGOS = [ # id, descripción, evidencia, probabilidad, impacto, dueño
('H01', 'El token de la Cloud API caduca el 19/05 y nadie tiene un recordatorio ni un procedimiento', 'Tabla 1: C04 creado el 20/03 con 60 días', 5, 4, 'Valeria'),
('H02', 'El dominio está en la cuenta personal de Don Ernesto, sin doble factor y con contraseña de 2019', 'Bloque 5: registrador, 0 de 5 controles', 3, 5, 'Don Ernesto'),
('H03', 'Una sola clave de Claude para producción y evaluación, en el espacio Default sin tope propio', 'Bloque 2: pista a7Qx en C01 y C02; 10 flujos', 3, 4, 'Valeria'),
('H04', 'La integración de Notion es de la cuenta personal de Valeria y ve 14 páginas, incluida Nómina', 'Bloque 4: 12 páginas sobrantes', 3, 4, 'Valeria'),
('H05', 'Ninguna credencial se ha rotado desde que se creó', 'Bloque 3: 0 rotaciones de 26', 3, 3, 'Valeria'),
('H06', 'La llave SSH vive solo en el portátil de Valeria y 10 consolas no tienen segundo administrador', 'Bloque 5: segundo administrador en 2 de 12', 2, 4, 'Valeria'),
('H07', 'Segundo factor por SMS en 3 consolas y ninguno en Notion, el dominio y Voyage AI', 'Bloque 5: factor fuerte en 6 de 12', 3, 3, 'Valeria'),
('H08', 'Gmail concedido con gmail.modify cuando bastan leer y crear borradores', 'Bloque 4: C10, 3 capacidades sobrantes', 2, 3, 'Valeria'),
('H09', 'La credencial Postgres TL escribe en 4 flujos que solo leen', 'Bloque 4: C18', 2, 3, 'Valeria'),
('H10', 'El Auth Token de Twilio sigue vivo en 3 flujos cuyo proveedor se retiró el Día 79', 'Bloque 2: C21 en 3 flujos', 2, 2, 'Valeria'),
('H11', 'Códigos de recuperación guardados solo en 4 de 12 consolas', 'Bloque 5', 2, 3, 'Valeria'),
('H12', 'Exportaciones antiguas con un identificador real de conexión de Make y URLs de webhook de prueba', 'Bloque 7', 2, 2, 'Valeria'),
('H13', 'El bot de Slack tiene 3 alcances de más (files:write, users:read, chat:write.public)', 'Bloque 4: C06', 1, 2, 'Valeria'),
('H14', 'El token de LinkedIn depende de la cuenta personal de Valeria como administradora', 'Bloque 5: LinkedIn, cuenta personal', 2, 3, 'Valeria'),
]
PLAZOS = {'crítica': 1, 'alta': 7, 'media': 30, 'baja': 90}
def severidad(r):
return 'crítica' if r >= 15 else 'alta' if r >= 9 else 'media' if r >= 4 else 'baja'
hallazgos = pd.DataFrame(HALLAZGOS, columns = ['id', 'descripcion', 'evidencia', 'P', 'I', 'dueno'])
hallazgos['R'] = hallazgos.P * hallazgos.I
hallazgos['severidad'] = hallazgos.R.map(severidad)
hallazgos['plazo_dias'] = hallazgos.severidad.map(PLAZOS)
hallazgos['limite'] = hallazgos.plazo_dias.map(lambda d: hoy + timedelta(days = d))
hallazgos['estado'] = 'abierto'
hallazgos = hallazgos.sort_values(['R', 'I', 'id'], ascending = [False, False, True]).reset_index(drop = True)
print(hallazgos[['id', 'descripcion', 'P', 'I', 'R', 'severidad', 'plazo_dias', 'limite', 'dueno']]
.assign(descripcion = hallazgos.descripcion.str[:58], limite = hallazgos.limite.map(lambda d: f'{d:%d/%m}')).to_string(index = False))
print('Por severidad:', hallazgos.severidad.value_counts().reindex(PLAZOS).fillna(0).astype(int).to_dict(),
f'| riesgo total: {hallazgos.R.sum()} | antes de la revisión mensual ({REVISION_MENSUAL:%d/%m}): {int((hallazgos.limite <= REVISION_MENSUAL).sum())} hallazgos')
print('Hoy mismo (24 horas):', ', '.join(hallazgos.id[hallazgos.severidad == 'crítica']), '| esta semana:', ', '.join(hallazgos.id[hallazgos.severidad == 'alta']))
ZONAS = {'baja': '#d8ead3', 'media': '#fff2cc', 'alta': '#f9cb9c', 'crítica': '#ea9999'}
fig, ax = plt.subplots(figsize = (8.6, 7))
for p in range(1, 6):
for i in range(1, 6):
ax.add_patch(Rectangle((p - 0.5, i - 0.5), 1, 1, facecolor = ZONAS[severidad(p * i)], edgecolor = 'white', lw = 2))
ax.text(p + 0.42, i - 0.42, str(p * i), ha = 'right', va = 'bottom', fontsize = 8, color = '#8a8a8a')
grupos = hallazgos.groupby(['P', 'I']).id.agg(list)
for (p, i), ids in grupos.items():
for k, ident in enumerate(ids):
ang = 2 * math.pi * k / len(ids)
r = 0 if len(ids) == 1 else 0.24
x, y = p + r * math.cos(ang), i + r * math.sin(ang)
ax.add_patch(Circle((x, y), 0.17, facecolor = '#34332f', edgecolor = 'white', lw = 1.2, zorder = 3))
ax.text(x, y, ident[1:], ha = 'center', va = 'center', fontsize = 8, color = 'white', fontweight = 'bold', zorder = 4)
ax.set_xlim(0.5, 5.5)
ax.set_ylim(0.5, 5.5)
ax.set_xticks(range(1, 6), ['1 remota', '2 poco probable', '3 posible', '4 probable', '5 casi segura'], fontsize = 9)
ax.set_yticks(range(1, 6), ['1 menor', '2 limitado', '3 moderado', '4 grave', '5 crítico'], fontsize = 9)
ax.set_xlabel('Probabilidad en los próximos 12 meses')
ax.set_ylabel('Impacto en clientes, dinero y datos')
for s, c in ZONAS.items():
ax.scatter([], [], marker = 's', s = 100, color = c, label = f'{s} ({PLAZOS[s]} {"día" if PLAZOS[s] == 1 else "días"})')
ax.legend(loc = 'upper left', frameon = False, fontsize = 9, title = 'Severidad y plazo', title_fontsize = 9)
ax.set_title('Matriz de riesgo de los 14 hallazgos (número = H)', fontsize = 12.5)
plt.tight_layout()
plt.show()
id descripcion P I R severidad plazo_dias limite dueno
H01 El token de la Cloud API caduca el 19/05 y nadie tiene un 5 4 20 crítica 1 22/04 Valeria
H02 El dominio está en la cuenta personal de Don Ernesto, sin 3 5 15 crítica 1 22/04 Don Ernesto
H03 Una sola clave de Claude para producción y evaluación, en 3 4 12 alta 7 28/04 Valeria
H04 La integración de Notion es de la cuenta personal de Valer 3 4 12 alta 7 28/04 Valeria
H05 Ninguna credencial se ha rotado desde que se creó 3 3 9 alta 7 28/04 Valeria
H07 Segundo factor por SMS en 3 consolas y ninguno en Notion, 3 3 9 alta 7 28/04 Valeria
H06 La llave SSH vive solo en el portátil de Valeria y 10 cons 2 4 8 media 30 21/05 Valeria
H08 Gmail concedido con gmail.modify cuando bastan leer y crea 2 3 6 media 30 21/05 Valeria
H09 La credencial Postgres TL escribe en 4 flujos que solo lee 2 3 6 media 30 21/05 Valeria
H11 Códigos de recuperación guardados solo en 4 de 12 consolas 2 3 6 media 30 21/05 Valeria
H14 El token de LinkedIn depende de la cuenta personal de Vale 2 3 6 media 30 21/05 Valeria
H10 El Auth Token de Twilio sigue vivo en 3 flujos cuyo provee 2 2 4 media 30 21/05 Valeria
H12 Exportaciones antiguas con un identificador real de conexi 2 2 4 media 30 21/05 Valeria
H13 El bot de Slack tiene 3 alcances de más (files:write, user 1 2 2 baja 90 20/07 Valeria
Por severidad: {'crítica': 2, 'alta': 4, 'media': 7, 'baja': 1} | riesgo total: 119 | antes de la revisión mensual (04/05): 6 hallazgos
Hoy mismo (24 horas): H01, H02 | esta semana: H03, H04, H05, H07

$14$ hallazgos: $2$ críticos, $4$ altos, $7$ medios y $1$ bajo, con un riesgo total de $119$ puntos y $6$ hallazgos cuya fecha límite cae antes de la revisión mensual del 4 de mayo. Los dos críticos se atienden hoy: el token de la Cloud API que caduca el 19 de mayo (H01, $R = 20$) y el dominio en una cuenta personal sin doble factor (H02, $R = 15$). Los cuatro altos tienen de plazo hasta el martes 28: la clave única de Claude (H03), la integración de Notion personal y con catorce páginas (H04), la ausencia total de rotaciones (H05) y los segundos factores débiles o inexistentes (H07). La Tabla 2 es el registro completo, con la columna de estado que se irá cerrando.
Tabla 2. Hallazgos de la auditoría del 21 de abril de 2026, ordenados por riesgo (P: probabilidad; I: impacto; R = P × I).
| Id | Hallazgo | Evidencia | P | I | R | Severidad | Plazo | Límite | Dueño | Estado |
|---|---|---|---|---|---|---|---|---|---|---|
| H01 | El token de la Cloud API caduca el 19/05 y nadie tiene un recordatorio ni un procedimiento | Tabla 1: C04 creado el 20/03 con 60 días | 5 | 4 | 20 | crítica | 24 horas | 22/04 | Valeria | abierto |
| H02 | El dominio está en la cuenta personal de Don Ernesto, sin doble factor y con contraseña de 2019 | Bloque 5: registrador, 0 de 5 controles | 3 | 5 | 15 | crítica | 24 horas | 22/04 | Don Ernesto | abierto |
| H03 | Una sola clave de Claude para producción y evaluación, en el espacio Default sin tope propio | Bloque 2: pista a7Qx en C01 y C02; 10 flujos | 3 | 4 | 12 | alta | 7 días | 28/04 | Valeria | abierto |
| H04 | La integración de Notion es de la cuenta personal de Valeria y ve 14 páginas, incluida Nómina | Bloque 4: 12 páginas sobrantes | 3 | 4 | 12 | alta | 7 días | 28/04 | Valeria | abierto |
| H05 | Ninguna credencial se ha rotado desde que se creó | Bloque 3: 0 rotaciones de 26 | 3 | 3 | 9 | alta | 7 días | 28/04 | Valeria | abierto |
| H07 | Segundo factor por SMS en 3 consolas y ninguno en Notion, el dominio y Voyage AI | Bloque 5: factor fuerte en 7.5 de 12 | 3 | 3 | 9 | alta | 7 días | 28/04 | Valeria | abierto |
| H06 | La llave SSH vive solo en el portátil de Valeria y 10 consolas no tienen segundo administrador | Bloque 5: segundo administrador en 2 de 12 | 2 | 4 | 8 | media | 30 días | 21/05 | Valeria | abierto |
| H08 | Gmail concedido con gmail.modify cuando bastan leer y crear borradores | Bloque 4: C10, 3 capacidades sobrantes | 2 | 3 | 6 | media | 30 días | 21/05 | Valeria | abierto |
| H09 | La credencial Postgres TL escribe en 4 flujos que solo leen | Bloque 4: C18 | 2 | 3 | 6 | media | 30 días | 21/05 | Valeria | abierto |
| H11 | Códigos de recuperación guardados solo en 4 de 12 consolas | Bloque 5 | 2 | 3 | 6 | media | 30 días | 21/05 | Valeria | abierto |
| H14 | El token de LinkedIn depende de la cuenta personal de Valeria como administradora | Bloque 5: LinkedIn, cuenta personal | 2 | 3 | 6 | media | 30 días | 21/05 | Valeria | abierto |
| H10 | El Auth Token de Twilio sigue vivo en 3 flujos cuyo proveedor se retiró el Día 79 | Bloque 2: C21 | 2 | 2 | 4 | media | 30 días | 21/05 | Valeria | abierto |
| H12 | Exportaciones antiguas con un identificador real de conexión de Make y URLs de webhook de prueba | Bloque 7 | 2 | 2 | 4 | media | 30 días | 21/05 | Valeria | abierto |
| H13 | El bot de Slack tiene 3 alcances de más (files:write, users:read, chat:write.public) | Bloque 4: C06 | 1 | 2 | 2 | baja | 90 días | 20/07 | Valeria | abierto |
La matriz de riesgo del bloque sitúa cada hallazgo por su probabilidad y su impacto sobre las zonas de severidad; los que comparten celda se reparten alrededor del centro para que se lean todos. La zona roja de arriba a la derecha solo tiene dos puntos, y los dos tienen fecha de mañana.
El hallazgo H12 viene de una búsqueda aparte: la de secretos filtrados. El Día 42 construyó un detector por patrones para el gancho pre-commit del repositorio de flujos; hoy se amplía con los prefijos conocidos de los proveedores del ecosistema (sk-ant- de Anthropic, xoxb- y xoxp- de Slack, EAA de los tokens de Meta, ntn_ y secret_ de Notion, ghp_ y github_pat_ de GitHub, las URL hooks.slack.com/services/T.../B.../... de los webhooks de Slack), con las cabeceras de autenticación escritas en un nodo HTTP, con los identificadores reales de conexión de Make y con una regla sin prefijo: las cadenas de alta entropía. La entropía de Shannon de una cadena mide cuántos bits de información lleva cada carácter:
$$ H(s) = -\sum_{c \in s} p(c) \log_2 p(c) $$
- $p(c)$ es la frecuencia relativa del carácter $c$ en la cadena; una cadena con todos los caracteres distintos y equiprobables tiene la entropía máxima, $\log_2$ del tamaño del alfabeto.
- Un identificador hexadecimal de $32$ caracteres como los de n8n ronda $1.7$ bits, un secreto aleatorio en base64 de $32$ caracteres ronda $4.5$ y una clave de ejemplo llena de ceros se queda en $3.4$; el umbral de $4.2$ bits separa los secretos reales de los identificadores y de los textos.
- La entropía no sabe qué es un secreto: ve “aleatorio” y avisa. Por eso devuelve falsos positivos (una ruta larga de un flujo de Power Automate) que una persona descarta en segundos, y por eso se combina con los prefijos y no los sustituye.
El bloque recorre las exportaciones de todos los días, la copia de trabajo del repositorio de flujos de los días 42 y 43, sus exportaciones normalizadas y el registro de ejecuciones del Día 75, clasifica cada coincidencia como “de ejemplo” cuando el valor lleva el marcador EJEMPLO-NO-REAL o está relleno de ceros (la convención de la serie) y como “a revisar” en otro caso, y enmascara todo lo que imprime:
# Búsqueda de secretos filtrados con expresiones regulares y entropía en exportaciones, registro y repositorio en Python:
PATRONES = {
'clave de Anthropic': r'sk-ant-[A-Za-z0-9_\-]{20,}',
'token de Slack': r'xox[abpr]-[A-Za-z0-9\-]{10,}',
'URL de webhook de Slack': r'https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+',
'token de Meta': r'\bEAA[A-Za-z0-9]{20,}',
'token de Notion': r'\b(?:ntn_|secret_)[A-Za-z0-9]{20,}',
'token de GitHub': r'\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}',
'cabecera de autenticación': r'"(?:x-api-key|authorization|X-API-Key)"\s*,\s*"value"\s*:\s*"([^"{]{8,})"',
'conexión real de Make': r'"__IMTCONN__"\s*:\s*([1-9]\d*)',
'URL de webhook de n8n': r'https?://[\w.\-]+/webhook(?:-test)?/[\w\-]+',
}
CANDIDATO = re.compile(r'(?<![A-Za-z0-9+/=_\-])[A-Za-z0-9+/=_\-]{32,}(?![A-Za-z0-9+/=_\-])')
def entropia(s): # bits por carácter (Shannon)
cuenta = Counter(s)
return -sum(c / len(s) * math.log2(c / len(s)) for c in cuenta.values())
def enmascarar(v):
return v[:6] + '…' + v[-3:] + f' ({len(v)} caracteres)' if len(v) > 12 else v[:3] + '…'
def clasificar(valor):
if 'EJEMPLO-NO-REAL' in valor or 'EJEMPLO' in valor or 'ejemplo' in valor or re.fullmatch(r'[0.\-A-Fa-f]*0{6,}[0-9A-Fa-f\-]*', valor):
return 'de ejemplo (publicable)'
return 'a revisar'
RUTAS = [Path('trigo_lento') / 'dia_42' / 'exportaciones', Path('trigo_lento') / 'dia_42' / 'flujos-trigo-lento',
Path('trigo_lento') / 'dia_42' / 'historial', Path('trigo_lento') / 'dia_43' / 'repositorio',
Path('trigo_lento') / 'dia_75' / 'registro_ejecuciones.csv'] + sorted(Path('trigo_lento').glob('dia_*/flujos'))
EXT = {'.json', '.csv', '.md', '.txt', '.sh', '.yml', '.yaml', '.env'}
archivos = sorted({a for r in RUTAS for a in ([r] if r.is_file() else r.rglob('*')) if a.is_file() and a.suffix.lower() in EXT})
coincidencias = []
for a in archivos:
texto = a.read_text(encoding = 'utf-8', errors = 'ignore')
for nombre, patron in PATRONES.items():
for m in re.finditer(patron, texto):
valor = m.group(1) if m.groups() else m.group(0)
coincidencias.append({'archivo': a.as_posix().replace('trigo_lento/', ''), 'tipo': nombre, 'valor': valor, 'clase': clasificar(valor)})
for m in CANDIDATO.finditer(texto):
v = m.group(0)
if entropia(v) >= 4.2 and not v.startswith('Salted__') and not re.fullmatch(r'[0-9a-f\-]+', v):
coincidencias.append({'archivo': a.as_posix().replace('trigo_lento/', ''), 'tipo': 'cadena de alta entropía', 'valor': v, 'clase': clasificar(v)})
hallados = pd.DataFrame(coincidencias).drop_duplicates(['archivo', 'tipo', 'valor'])
print(f'Archivos revisados: {len(archivos)} ({sum(a.stat().st_size for a in archivos) / 1e6:.1f} MB) | coincidencias distintas: {len(hallados)}')
print(pd.crosstab(hallados.tipo, hallados.clase).to_string())
revisar = hallados[hallados.clase == 'a revisar']
print(f'A revisar: {len(revisar)} en {revisar.archivo.nunique()} archivos')
for f in revisar.itertuples():
print(f' {f.archivo}: {f.tipo}: {enmascarar(f.valor)}')
print(f'El registro de ejecuciones ({sum(1 for _ in open("trigo_lento/dia_75/registro_ejecuciones.csv", encoding = "utf-8")) - 1} filas): '
f'{len(hallados[hallados.archivo.str.contains("registro")])} coincidencias')
print(f'Entropía de referencia: "{enmascarar("sk-ant-api03-EJEMPLO-NO-REAL-0000000000000000")}" = '
f'{entropia("sk-ant-api03-EJEMPLO-NO-REAL-0000000000000000"):.2f} bits; identificador hexadecimal de 32 = {entropia("0b1c000000004000800000000000a1f3"):.2f}; '
f'32 caracteres aleatorios de base64 = {entropia("".join(rng.choice(list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), 32))):.2f}')
Archivos revisados: 78 (1.3 MB) | coincidencias distintas: 20
clase a revisar de ejemplo (publicable)
tipo
URL de webhook de n8n 2 0
cabecera de autenticación 0 2
cadena de alta entropía 1 12
clave de Anthropic 0 1
conexión real de Make 2 0
A revisar: 5 en 5 archivos
dia_13/flujos/power_automate/recordatorio_recogida_v0.json: cadena de alta entropía: Logic/…ion (43 caracteres)
dia_32/flujos/make/brujula_diario_v0.blueprint.json: conexión real de Make: 482…
dia_36/flujos/n8n/pin_data_prueba.json: URL de webhook de n8n: https:…app (60 caracteres)
dia_42/exportaciones/2026-03-14/make/escenario_4127.json: conexión real de Make: 481…
dia_43/flujos/n8n/pin_data_webhook.json: URL de webhook de n8n: https:…app (67 caracteres)
El registro de ejecuciones (3056 filas): 0 coincidencias
Entropía de referencia: "sk-ant…000 (45 caracteres)" = 3.36 bits; identificador hexadecimal de 32 = 1.69; 32 caracteres aleatorios de base64 = 4.54
$78$ archivos ($1.3$ MB) y $20$ coincidencias: $15$ son valores de ejemplo (la clave sk-ant-... del blueprint v0 del Día 44, las dos cabeceras de autenticación de ese mismo flujo y de un escenario del Día 32, doce cadenas de alta entropía que son claves de ejemplo de los JSON) y $5$ quedan a revisar en $5$ archivos: un identificador real de conexión de Make (482…) en brujula_diario_v0 del Día 32, otro (481…) en una exportación sin normalizar del Día 42, dos URL de webhook de n8n dentro de datos fijados (pinData) de los días 36 y 43 y un falso positivo, la ruta de una acción de Power Automate del Día 13. El registro de ejecuciones, con $3,056$ filas, tiene $0$ coincidencias, que es la prueba de que el registro del Día 75 cumple el código de honor: no guarda contenido. Ningún secreto vivo apareció; los dos identificadores de conexión y las dos URL son un hallazgo medio (H12) porque una exportación antigua con datos reales es el tipo de archivo que se adjunta a un foro sin pensar, como contó el Día 48.
Simulacro de restauración del ecosistema
Hasta aquí la auditoría ha mirado llaves. La segunda pregunta de Don Ernesto es otra: si se pierde el servidor, ¿cuánto tardamos en volver? El Día 35 hizo esa prueba para n8n solo, con un RTO de $2$ horas y un segundo simulacro de $43$ minutos. Desde entonces el ecosistema creció: hay $23$ flujos, una base del negocio en SQLite, un servidor MCP, un indexador de Notion, webhooks registrados en Meta y en Slack, y un dominio que apunta a todo. El simulacro de hoy es del ecosistema entero, con la regla que lo hace honesto: un servidor nuevo desde cero, con lo que hay en las copias, sin mirar el servidor viejo. Si en algún paso hace falta abrir el servidor viejo para copiar algo, ese algo no estaba respaldado, y el simulacro lo encontró.
Fases cronometradas
Las fases salen de la publicación NIST SP 800-34 Rev. 1, la guía de planes de contingencia, adaptada a una panadería: aprovisionar, instalar la plataforma, restaurar los datos, colocar los secretos, importar la configuración, reconectar el mundo exterior y probar. Valeria las planeó con tiempos objetivo que suman $125$ minutos y las ejecutó con un cronómetro en la mano. El guion de terminal es el siguiente; se muestra con las direcciones y los nombres de ejemplo, y las dos líneas que leen secretos los toman del gestor de contraseñas, nunca del servidor caído:
# Simulacro de restauración del ecosistema en un servidor nuevo, sin mirar el servidor viejo, en Bash:
set -euo pipefail
# 1. Aprovisionar el servidor desde la consola del proveedor (la cuenta del negocio, no la de una persona)
# y entrar con la llave SSH que guarda el gestor de contraseñas:
ssh -i ~/.ssh/tl_restauracion root@203.0.113.10 # dirección de documentación (RFC 5737), no la real
# 2. Docker, docker compose y la carpeta del proyecto desde el repositorio de configuración:
curl -fsSL https://get.docker.com | sh
git clone https://github.com/trigo-lento/flujos-trigo-lento.git /opt/trigo_lento
cd /opt/trigo_lento
# 3. Postgres solo: cargar la copia cifrada de las 2:00 sin que n8n escriba todavía:
docker compose up -d postgres
rclone copy externo:tl-respaldos/diaria/n8n_2026-04-21.dump.gpg .
gpg --batch --passphrase-file /root/.frase_respaldo -d n8n_2026-04-21.dump.gpg > n8n.dump
docker compose exec -T postgres pg_restore -U n8n -d n8n --clean --if-exists --no-owner < n8n.dump
# 4. La clave de cifrado sale del gestor de secretos, nunca del servidor caído, y se comprueba su huella:
export N8N_ENCRYPTION_KEY="$(op read 'op://Trigo Lento/N8N_ENCRYPTION_KEY/password')"
printf '%s' "$N8N_ENCRYPTION_KEY" | sha256sum | cut -c1-12 # debe coincidir con la huella anotada: 8e7272254ab0
printf 'N8N_ENCRYPTION_KEY=%s\n' "$N8N_ENCRYPTION_KEY" >> .env
docker compose up -d n8n
# 5. Credenciales (cifradas, de la exportación de hoy) y workflows (de la etiqueta del repositorio):
docker compose exec -u node n8n n8n import:credentials --separate --input=/home/node/.n8n/respaldos/2026-04-21/credenciales/
docker compose exec -u node n8n n8n import:workflow --separate --input=/home/node/.n8n/repositorio/flujos/n8n/
# import:workflow deja los workflows desactivados: se reactivan los 23 del inventario del Día 79.
# 6. La base del negocio vuelve de la copia de las 2:00:
rclone copy externo:tl-respaldos/diaria/trigo_lento_2026-04-21.sqlite.gpg .
gpg --batch --passphrase-file /root/.frase_respaldo -d trigo_lento_2026-04-21.sqlite.gpg > /opt/trigo_lento/datos/trigo_lento.sqlite
# 7. El webhook de Meta apunta al servidor nuevo y el DNS cambia (TTL de 3600 s: hasta una hora de propagación):
curl -s -X POST "https://graph.facebook.com/v24.0/APP_ID/subscriptions" \
-d "object=whatsapp_business_account&callback_url=https://n8n.trigolento.example/webhook/pedidos-whatsapp-v2" \
-d "verify_token=$(op read 'op://Trigo Lento/Meta verify token/password')&access_token=$(op read 'op://Trigo Lento/Meta app token/password')"
# 8. Pruebas de humo, sin escribir a ningún cliente (número de prueba y webhook de salud):
curl -s https://n8n.trigolento.example/webhook/salud-tl
python -I -X utf8 pruebas/prueba_de_humo.py --numero "+52 55 0000 0001"
# Salida de ejemplo (puede variar):
# 8e7272254ab0
# Successfully imported 21 credentials.
# Successfully imported 23 workflows.
# {"success":true}
# {"estado":"ok","workflows_activos":23,"ultima_ejecucion":"2026-04-21T12:49:03-06:00"}
# Prueba de humo: 6 de 6 comprobaciones correctas en 27 min (pedido de prueba, factura de prueba, Slack, Notion, Gmail borrador, informe)
Tres detalles del guion importan más que el resto. Primero, el orden de las fases 3 y 4: Postgres se levanta y se restaura antes de que n8n arranque, y la clave de cifrado se coloca y se comprueba por su huella SHA-256 antes de docker compose up -d n8n; si n8n arranca sin la variable, genera una clave nueva y las credenciales de la copia quedan ilegibles para siempre (Día 35). Segundo, por qué se importan workflows desde el repositorio si la copia de Postgres ya los trae: la copia es de las 2:00 y el repositorio tiene la etiqueta de cada cambio (Día 42), así que importar desde la etiqueta deja los flujos exactamente como estaban al final del día anterior, con RPO cero para los workflows; el precio, documentado por n8n, es que import:workflow los deja desactivados y hay que reactivar los $23$ del inventario a mano. Tercero, el webhook de Meta se vuelve a suscribir apuntando al dominio, no a la dirección IP, para que el cambio de servidor sea un cambio de DNS y no un cambio en Meta; a cambio, el tiempo de propagación del DNS entra en el cronómetro.
El bloque registra lo planeado y lo medido por fase, calcula el RTO y el RPO, y hace la cuenta del escenario que el Día 35 dejó dicho y hoy se cuantifica: qué pasaría si N8N_ENCRYPTION_KEY se hubiera perdido:
# Simulacro cronometrado: fases con tiempo objetivo y medido, RTO, RPO y el escenario de la clave perdida en Python:
FASES = [ # fase, objetivo (min), medido (min), incidencia registrada por Valeria
('1. Aprovisionar el servidor nuevo', 20, 22, ''),
('2. Instalar Docker y docker compose', 15, 17, ''),
('3. Restaurar Postgres desde la copia cifrada', 10, 9, ''),
('4. Colocar N8N_ENCRYPTION_KEY y comprobar su huella', 15, 12, 'la huella coincidió a la primera'),
('5. Importar credenciales y workflows del repositorio', 10, 19, '23 workflows llegaron desactivados: reactivarlos a mano'),
('6. Restaurar trigo_lento.sqlite', 5, 6, ''),
('7. Apuntar el webhook de Meta y el DNS', 30, 58, 'TTL del DNS de 3600 s: 41 min de propagación'),
('8. Pruebas de humo', 20, 27, 'firma rechazada: secreto de la aplicación pegado con un espacio al final'),
]
RTO_OBJETIVO, RPO_OBJETIVO = 240, 24 * 60 # minutos
ultima_copia, inicio_simulacro = 2 * 60, 10 * 60 # 02:00 y 10:00 en minutos del día
simulacro = pd.DataFrame(FASES, columns = ['fase', 'objetivo', 'medido', 'incidencia'])
simulacro['desvio'] = simulacro.medido - simulacro.objetivo
simulacro['fin_objetivo'] = simulacro.objetivo.cumsum()
simulacro['fin_medido'] = simulacro.medido.cumsum()
print(simulacro[['fase', 'objetivo', 'medido', 'desvio', 'incidencia']].to_string(index = False))
rto_medido = int(simulacro.medido.sum())
print(f'Plan: {simulacro.objetivo.sum()} min | medido: {rto_medido} min ({rto_medido // 60} h {rto_medido % 60} min) | RTO objetivo: {RTO_OBJETIVO} min | '
f'holgura: {RTO_OBJETIVO - rto_medido} min | cumplido: {rto_medido <= RTO_OBJETIVO}')
print(f'Fases con desvío mayor del 50 %: {", ".join(simulacro.fase[simulacro.desvio > 0.5 * simulacro.objetivo].str[:1])} | '
f'el DNS explica el {simulacro.desvio.max() / simulacro.desvio.clip(lower = 0).sum():.0%} del retraso')
rpo_medido = inicio_simulacro - ultima_copia
print(f'RPO: última copia a las 02:00, caída simulada a las 10:00: {rpo_medido / 60:.0f} h perdidas de ejecuciones y encargos | objetivo {RPO_OBJETIVO // 60} h | '
f'workflows: 0 h (exportados tras cada cambio)')
# Si N8N_ENCRYPTION_KEY se hubiera perdido: ninguna credencial se abre y hay que volver a emitirlas todas.
credenciales_n8n = int(referencias[referencias.plataforma == 'n8n'].credencial.nunique())
MINUTOS_REEMISION = {'token OAuth': 20, 'clave de API': 10, 'contraseña': 8, 'secreto de firma': 12, 'URL de webhook': 5, 'token de servicio': 10, 'token de integración': 10}
en_n8n = inventario[inventario.guardado.str.contains('credencial de n8n')]
reemision = int(sum(MINUTOS_REEMISION[t] for t in en_n8n.tipo))
sin_fase_4 = rto_medido - 12
print(f'Clave perdida: {credenciales_n8n} credenciales de n8n ({len(en_n8n)} secretos distintos) por reemitir: {reemision} min de trámites + '
f'{sin_fase_4} min del resto = {reemision + sin_fase_4} min ({(reemision + sin_fase_4) / 60:.1f} h): RTO incumplido en {reemision + sin_fase_4 - RTO_OBJETIVO} min')
fig, ax = plt.subplots(figsize = (11.5, 5.8))
ys = np.arange(len(simulacro))
for y, f in zip(ys, simulacro.itertuples()):
ax.barh(y - 0.19, f.objetivo, left = f.fin_objetivo - f.objetivo, height = 0.36, color = '#c9c7c1', label = 'plan' if y == 0 else None)
ax.barh(y + 0.19, f.medido, left = f.fin_medido - f.medido, height = 0.36,
color = '#c0392b' if f.desvio > 0.5 * f.objetivo else '#2a78d6', label = 'medido' if y == 0 else None)
ax.text(f.fin_medido + 2, y + 0.19, f'{f.medido} min' + (f' (+{f.desvio})' if f.desvio > 0 else ''), va = 'center', fontsize = 8.8)
ax.axvline(RTO_OBJETIVO, color = '#c0392b', ls = '--', lw = 1.6)
ax.text(RTO_OBJETIVO + 3, 0.6, f'RTO objetivo:\n{RTO_OBJETIVO} min', fontsize = 9.5, color = '#c0392b', va = 'center')
ax.axvline(rto_medido, color = '#2a78d6', ls = ':', lw = 1.4)
ax.text(rto_medido + 3, 2.4, f'medido:\n{rto_medido} min', fontsize = 9.5, color = '#2a78d6', va = 'center')
ax.set_yticks(ys, simulacro.fase, fontsize = 9.5)
ax.invert_yaxis()
ax.set_xlim(0, 275)
ax.set_xlabel('Minutos desde el inicio del simulacro (martes 21 de abril, 10:00)')
ax.set_title('Simulacro de restauración: 125 min planeados y 170 medidos', fontsize = 12.5)
ax.scatter([], [], marker = 's', s = 80, color = '#c0392b', label = 'desvío mayor del 50 %')
ax.legend(frameon = False, loc = 'center right', fontsize = 9)
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
fase objetivo medido desvio incidencia
1. Aprovisionar el servidor nuevo 20 22 2
2. Instalar Docker y docker compose 15 17 2
3. Restaurar Postgres desde la copia cifrada 10 9 -1
4. Colocar N8N_ENCRYPTION_KEY y comprobar su huella 15 12 -3 la huella coincidió a la primera
5. Importar credenciales y workflows del repositorio 10 19 9 23 workflows llegaron desactivados: reactivarlos a mano
6. Restaurar trigo_lento.sqlite 5 6 1
7. Apuntar el webhook de Meta y el DNS 30 58 28 TTL del DNS de 3600 s: 41 min de propagación
8. Pruebas de humo 20 27 7 firma rechazada: secreto de la aplicación pegado con un espacio al final
Plan: 125 min | medido: 170 min (2 h 50 min) | RTO objetivo: 240 min | holgura: 70 min | cumplido: True
Fases con desvío mayor del 50 %: 5, 7 | el DNS explica el 57% del retraso
RPO: última copia a las 02:00, caída simulada a las 10:00: 8 h perdidas de ejecuciones y encargos | objetivo 24 h | workflows: 0 h (exportados tras cada cambio)
Clave perdida: 21 credenciales de n8n (13 secretos distintos) por reemitir: 185 min de trámites + 158 min del resto = 343 min (5.7 h): RTO incumplido en 103 min

$170$ minutos medidos frente a $125$ planeados y un RTO objetivo de $240$: el ecosistema volvió en $2$ horas y $50$ minutos, con $70$ de holgura. Dos fases se salieron del plan en más de un $50,%$: la importación de flujos ($19$ minutos en lugar de $10$, por los $23$ workflows desactivados que hubo que reactivar uno a uno) y el webhook con el DNS ($58$ en lugar de $30$, porque el registro tenía un TTL de $3,600$ segundos y la propagación tardó $41$ minutos), que explica el $57,%$ del retraso total. Las pruebas de humo también se alargaron $7$ minutos por un error humano instructivo: el secreto de la aplicación de Meta se pegó con un espacio al final y el webhook rechazó la primera firma. Ninguno de los tres retrasos pone en riesgo el objetivo, pero los tres tienen corrección barata: bajar el TTL del DNS a $300$ segundos antes del próximo simulacro, guardar la lista de workflows activos junto a la copia para reactivarlos con la API en lugar de a mano, y comprobar la firma con una pulsación de prueba antes de las pruebas de humo. La Tabla 3 resume las fases.
Tabla 3. Simulacro de restauración del 21 de abril de 2026: fases, tiempos objetivo y medidos, incidencias y resultado frente a los objetivos.
| Fase | Objetivo (min) | Medido (min) | Desvío | Incidencia | Pruebas de humo |
|---|---|---|---|---|---|
| 1. Aprovisionar el servidor nuevo | 20 | 22 | +2 | ninguna | no aplica |
| 2. Instalar Docker y docker compose | 15 | 17 | +2 | ninguna | no aplica |
| 3. Restaurar Postgres desde la copia cifrada | 10 | 9 | -1 | ninguna | pg_restore sin errores |
4. Colocar N8N_ENCRYPTION_KEY y comprobar su huella | 15 | 12 | -3 | la huella coincidió a la primera | credenciales legibles |
| 5. Importar credenciales y workflows del repositorio | 10 | 19 | +9 | 23 workflows llegaron desactivados: reactivarlos a mano | 23 activos |
6. Restaurar trigo_lento.sqlite | 5 | 6 | +1 | ninguna | $468$ encargos del mes presentes |
| 7. Apuntar el webhook de Meta y el DNS | 30 | 58 | +28 | TTL del DNS de $3,600$ s: $41$ min de propagación | suscripción aceptada |
| 8. Pruebas de humo | 20 | 27 | +7 | firma rechazada: secreto pegado con un espacio al final | 6 de 6 |
| Total | 125 | 170 | +45 | RTO objetivo $240$ min: cumplido con $70$ de holgura | RPO $8$ h frente a $24$ h |
RTO y RPO
El objetivo de tiempo de recuperación (o RTO, por sus siglas en inglés: “Recovery Time Objective”) es cuánto puede estar caído el ecosistema, y el objetivo de punto de recuperación (o RPO: “Recovery Point Objective”) es cuántos datos se pueden perder, medidos en tiempo desde la última copia. Trigo Lento fijó $4$ horas y $24$ horas: en $4$ horas Valeria y las personas de mostrador pueden atender con la libreta sin que se pierda un encargo (Día 81), y una copia diaria a las 2:00 garantiza que, pase lo que pase, se pierde como mucho un día de ejecuciones. El simulacro midió $170$ minutos de RTO y, con una caída a las 10:00, $8$ horas de RPO para la base de ejecuciones y para trigo_lento.sqlite, y cero para los workflows, que se exportan tras cada cambio.
La parte del bloque que más pesa es la última línea: si N8N_ENCRYPTION_KEY se hubiera perdido, la copia de Postgres se restaura igual pero ninguna de las $21$ credenciales de n8n se abre, y los $13$ secretos distintos que hay detrás tendrían que volver a emitirse en trece consolas distintas, con sus esperas (un token de Meta exige la consola de Business Suite y su doble factor; un OAuth de Google, autorizar de nuevo la aplicación). Valeria estimó los trámites en $185$ minutos, que sumados a las otras fases dan $343$ minutos, $5.7$ horas: el RTO se incumple en $103$ minutos. Esa es la razón, ahora con número, por la que la clave vive fuera del servidor, en el gestor de contraseñas, con su huella anotada al lado y con acceso de emergencia para Don Ernesto.
El RPO de $8$ horas cumple el objetivo, pero conviene saber qué significa en encargos, porque una ejecución perdida es un pedido que habría que recapturar leyendo WhatsApp. Con una copia cada $T$ horas la pérdida esperada es $T/2$ en horas (Día 35); en encargos, hay que contar cuántos llegan en ese tiempo, y los encargos no llegan de noche. Si la caída ocurre en un instante $t$ uniforme del día, la última copia fue en $c(t)$ y los encargos llegan a tasa $\lambda(u)$ (cero fuera del horario de atención):
$$ E[L] = \frac{1}{24} \int_{0}^{24} \int_{c(t)}^{t} \lambda(u), du, dt $$
- $\lambda(u) = 18 / 13$ encargos por hora entre las 7:00 y las 20:00, y $0$ el resto del día: los $18$ encargos diarios de Trigo Lento repartidos en $13$ horas.
- $c(t)$ es la última copia antes de $t$; con una copia diaria a las 2:00, una caída a las 9:00 pierde dos horas de encargos y una caída a la 1:00 pierde todos los de ayer.
- El plan de la serie calculó el caso diario a mano: $\frac{1}{24}\left(\frac{18 \cdot 13}{2} + 6 \cdot 18\right) = 9.375$ encargos, y el caso de una copia cada hora en horario de atención, $\frac{13}{24} \cdot 0.5 \cdot \frac{18}{13} = 0.375$.
- El bloque integra la fórmula minuto a minuto y la contrasta con una simulación de llegadas de Poisson, que además da el percentil $95$: el mal día, no el promedio.
# Pérdida esperada de encargos según el intervalo de copia, analítica y simulada, en Python:
ENCARGOS_DIA, APERTURA, CIERRE, MINUTOS_RECAPTURA = 18, 7, 20, 6
POLITICAS = {'diaria a las 2:00': [2], 'cada 6 horas (2, 8, 14, 20)': [2, 8, 14, 20],
'cada hora en horario de atención': [2] + list(range(7, 21))}
def solape(a, b, lo, hi): # horas del intervalo (a, b] que caen dentro de [lo, hi)
return max(0.0, min(b, hi) - max(a, lo))
def perdida_analitica(copias):
# Caída en un instante t uniforme del día; se pierden los encargos llegados desde la última copia c,
# que puede ser de ayer (c - 24): la llegada es uniforme entre las 7:00 y las 20:00 de cada día.
tasa = ENCARGOS_DIA / (CIERRE - APERTURA)
instantes = np.arange(0, 24, 1 / 60)
total = 0.0
for t in instantes:
c = max([x for x in copias if x <= t] or [max(copias) - 24])
total += tasa * (solape(c, t, APERTURA, CIERRE) + solape(c, t, APERTURA - 24, CIERRE - 24))
return total / len(instantes)
def perdida_simulada(copias, dias = 50_000):
perdidas = np.empty(dias)
for k in range(dias):
llegadas = np.concatenate([rng.uniform(APERTURA, CIERRE, rng.poisson(ENCARGOS_DIA)) - 24, # ayer
rng.uniform(APERTURA, CIERRE, rng.poisson(ENCARGOS_DIA))]) # hoy
t = rng.uniform(0, 24)
ultima = max([c for c in copias if c <= t] or [max(copias) - 24])
perdidas[k] = ((llegadas > ultima) & (llegadas <= t)).sum()
return perdidas.mean(), np.percentile(perdidas, 95)
filas = []
for nombre, copias in POLITICAS.items():
media, p95 = perdida_simulada(copias)
filas.append({'política': nombre, 'copias al día': len(copias), 'analítica': perdida_analitica(copias), 'simulada': media,
'p95': p95, 'minutos de recaptura': media * MINUTOS_RECAPTURA})
perdidas = pd.DataFrame(filas)
print(perdidas.to_string(index = False, formatters = {'analítica': '{:.3f}'.format, 'simulada': '{:.3f}'.format, 'p95': '{:.0f}'.format,
'minutos de recaptura': '{:.1f}'.format}))
print(f'Fórmula del plan para la copia diaria: (18 · 13 / 2 + 6 · 18) / 24 = {(18 * 13 / 2 + 6 * 18) / 24:.3f}; '
f'cada hora en atención: 13 / 24 · 0.5 · 18 / 13 = {13 / 24 * 0.5 * 18 / 13:.3f}')
print(f'Copia cada hora de 7:00 a 20:00: {len(POLITICAS["cada hora en horario de atención"]) - 1} copias más al día; '
f'tamaño por copia del Día 35: 16.2 MB; espacio adicional con retención de 7 días: {13 * 7 * 16.2 / 1000:.2f} GB')
política copias al día analítica simulada p95 minutos de recaptura
diaria a las 2:00 1 9.369 9.457 22 56.7
cada 6 horas (2, 8, 14, 20) 4 2.100 2.081 8 12.5
cada hora en horario de atención 15 0.369 0.371 2 2.2
Fórmula del plan para la copia diaria: (18 · 13 / 2 + 6 · 18) / 24 = 9.375; cada hora en atención: 13 / 24 · 0.5 · 18 / 13 = 0.375
Copia cada hora de 7:00 a 20:00: 14 copias más al día; tamaño por copia del Día 35: 16.2 MB; espacio adicional con retención de 7 días: 1.47 GB
La integral y la simulación coinciden con el cálculo del plan: $9.37$ encargos perdidos en promedio con la copia diaria ($9.46$ simulados, con un percentil $95$ de $22$: una caída a última hora de la tarde pierde el día entero), $2.10$ con una copia cada seis horas y $0.37$ con una copia cada hora en horario de atención, que en minutos de recaptura a $6$ minutos por encargo (Día 1) son $56.7$, $12.5$ y $2.2$. Las $14$ copias adicionales al día cuestan, con el tamaño de $16.2$ MB medido el Día 35 y siete días de retención, $1.47$ GB más de almacenamiento; a los precios de almacenamiento de objetos del proveedor, es menos de un peso al mes. Valeria decide la copia horaria de 7:00 a 20:00 y deja el RPO formal en $24$ horas, porque un objetivo se fija por lo que se puede garantizar y no por lo que normalmente se consigue.
La Figura 3 resume el simulacro como lo que es para el obrador: reconstruir la casa en otro local con lo que hay en la caja fuerte, y un reloj que dice si se llegó a tiempo.
Figura 3. Reconstruir la casa con los planos guardados: lo que hay en la caja fuerte (copias cifradas, exportaciones, repositorio, clave de cifrado con su huella y la base del negocio) levanta el ecosistema en un servidor nuevo en ocho fases cronometradas; el reloj marca 2 h 50 min medidos frente a un RTO de 4 h.
Un simulacro de restauración se hace con una copia de datos que contiene nombres, teléfonos y direcciones de clientes reales, y el servidor nuevo es, durante unas horas, una segunda copia de todo el negocio. Tres reglas lo mantienen dentro de la ley de datos personales: la copia viaja cifrada y se descifra solo en el servidor del simulacro; el servidor se destruye al terminar, con la orden del proveedor y una comprobación de que ya no responde; y durante el simulacro el webhook de Meta no se apunta al servidor nuevo hasta que el viejo se ha desconectado, para que ningún cliente reciba dos respuestas ni ninguna conversación se parta en dos. Si el simulacro se hiciera con datos de prueba, como el entorno de ensayo del Día 43, no mediría la restauración de la base real, que es lo que importa.
El informe y el plan de corrección
Una auditoría termina en un documento que alguien que no la hizo pueda leer en cinco minutos y usar en tres meses. El de Trigo Lento tiene cuatro partes: un resumen de una página para Don Ernesto, en sus palabras (llaves, puertas, cuánto tardamos en volver); los hallazgos ordenados por severidad, con su fecha límite y su dueño; las fases del simulacro con sus tiempos; y el calendario, que engancha con el gobierno del Día 79. La próxima auditoría completa queda para el lunes 5 de octubre de 2026, seis meses después, con la revisión trimestral de julio en medio como punto de control de los hallazgos de hoy. El bloque genera el archivo desde las tablas anteriores y, antes de terminar, le pasa los patrones del Bloque 7 para comprobar que el informe de la auditoría no contiene ningún secreto:
# Generación de auditoria_seguridad_2026-04.md con el resumen de una página y los hallazgos ordenados en Python:
proxima_auditoria = date(2026, 10, 5) # primer lunes de octubre: seis meses, con la revisión trimestral de julio en medio
resumen = [
'# Auditoría de seguridad del ecosistema de Trigo Lento (abril de 2026)', '',
f'Fecha: {hoy:%d/%m/%Y}. Auditora: Valeria Ortiz. Alcance: {len(inventario)} credenciales, {len(cuentas)} consolas, '
f'{len(activos)} flujos activos, copias y restauración. Criterio: código de honor, controles de los días 17, 35 y 64 y CIS Controls v8.1.', '',
'## Resumen para Don Ernesto', '',
f'- Llaves de la casa: {len(inventario)}. Las contamos todas; {int((inventario.cuenta == "personal").sum())} están a nombre de una persona y no del negocio.',
f'- Ninguna llave se ha cambiado desde que se hizo; una está vencida (el dominio, de 2019) y {int((inventario.estado == "por vencer (30 días)").sum())} vencen en 30 días.',
f'- Hay {alcances.sobran.sum()} permisos que abren puertas que no usamos; se cierran sin que nada deje de funcionar.',
f'- Hallazgos: {len(hallazgos)} ({hallazgos.severidad.value_counts().get("crítica", 0)} críticos, se atienden hoy; '
f'{hallazgos.severidad.value_counts().get("alta", 0)} altos, esta semana).',
f'- Si el servidor desapareciera, el ecosistema vuelve en {rto_medido // 60} h {rto_medido % 60} min (probado hoy); se perderían como máximo las ejecuciones desde las 2:00.',
f'- Próxima auditoría completa: {proxima_auditoria:%d/%m/%Y}; revisión de permisos y credenciales: {REVISION_TRIMESTRAL:%d/%m/%Y}.', '',
'## Hallazgos por severidad', '',
'| Id | Hallazgo | Evidencia | P | I | Severidad | Límite | Dueño | Estado |', '|---|---|---|---|---|---|---|---|---|',
]
for h in hallazgos.itertuples():
resumen.append(f'| {h.id} | {h.descripcion} | {h.evidencia} | {h.P} | {h.I} | {h.severidad} | {h.limite:%d/%m/%Y} | {h.dueno} | {h.estado} |')
resumen += ['', '## Simulacro de restauración', '', '| Fase | Objetivo (min) | Medido (min) | Incidencia |', '|---|---|---|---|']
resumen += [f'| {f.fase} | {f.objetivo} | {f.medido} | {f.incidencia or "ninguna"} |' for f in simulacro.itertuples()]
resumen += ['', f'RTO: {rto_medido} min medido frente a {RTO_OBJETIVO} objetivo. RPO: {rpo_medido // 60} h medido frente a {RPO_OBJETIVO // 60} h objetivo.', '',
'## Inventario de credenciales', '', 'Ver `inventario_credenciales.csv` (sin valores; solo nombres, dueños, alcances y fechas).', '',
'## Calendario', '', f'- {REVISION_MENSUAL:%d/%m/%Y}: comprobar el cierre de los hallazgos críticos y altos.',
f'- {REVISION_TRIMESTRAL:%d/%m/%Y}: revisión trimestral de precios, permisos y credenciales (Día 79).',
f'- {proxima_auditoria:%d/%m/%Y}: auditoría completa con simulacro de restauración.']
ruta_informe = carpeta / 'auditoria_seguridad_2026-04.md'
ruta_informe.write_text('\n'.join(resumen) + '\n', encoding = 'utf-8')
print(f'{ruta_informe.as_posix()}: {len(resumen)} líneas, {ruta_informe.stat().st_size} bytes')
print('\n'.join(resumen[:12]))
print('...')
print('\n'.join(resumen[14:18]))
secretos_en_informe = sum(len(re.findall(p, ruta_informe.read_text(encoding = 'utf-8'))) for p in PATRONES.values())
print(f'Secretos en el informe según los patrones del Bloque 7: {secretos_en_informe}')
trigo_lento/dia_80/auditoria_seguridad_2026-04.md: 56 líneas, 4768 bytes
# Auditoría de seguridad del ecosistema de Trigo Lento (abril de 2026)
Fecha: 21/04/2026. Auditora: Valeria Ortiz. Alcance: 26 credenciales, 12 consolas, 23 flujos activos, copias y restauración. Criterio: código de honor, controles de los días 17, 35 y 64 y CIS Controls v8.1.
## Resumen para Don Ernesto
- Llaves de la casa: 26. Las contamos todas; 3 están a nombre de una persona y no del negocio.
- Ninguna llave se ha cambiado desde que se hizo; una está vencida (el dominio, de 2019) y 3 vencen en 30 días.
- Hay 35 permisos que abren puertas que no usamos; se cierran sin que nada deje de funcionar.
- Hallazgos: 14 (2 críticos, se atienden hoy; 4 altos, esta semana).
- Si el servidor desapareciera, el ecosistema vuelve en 2 h 50 min (probado hoy); se perderían como máximo las ejecuciones desde las 2:00.
- Próxima auditoría completa: 05/10/2026; revisión de permisos y credenciales: 06/07/2026.
...
| Id | Hallazgo | Evidencia | P | I | Severidad | Límite | Dueño | Estado |
|---|---|---|---|---|---|---|---|---|
| H01 | El token de la Cloud API caduca el 19/05 y nadie tiene un recordatorio ni un procedimiento | Tabla 1: C04 creado el 20/03 con 60 días | 5 | 4 | crítica | 22/04/2026 | Valeria | abierto |
Secretos en el informe según los patrones del Bloque 7: 0
El informe tiene $56$ líneas y $0$ secretos según los mismos patrones que buscan secretos en los flujos. Las seis líneas del resumen son las que Don Ernesto lee de pie, con la bandeja del horno esperando: $26$ llaves, $3$ a nombre de una persona; ninguna cambiada, una vencida, tres que vencen en un mes; $35$ puertas que se cierran sin que nada deje de funcionar; $14$ hallazgos, dos para hoy; y $2$ horas y $50$ minutos para volver, probadas. Su respuesta es la de alguien que cuenta llaves cada noche: “Lo del dominio lo arreglamos hoy usted y yo, con el correo de la panadería. Y lo del cronómetro, me gusta: a mí nadie me había dicho nunca cuánto tardamos en volver.”
El plan de corrección sale de la Tabla 2 sin añadir nada: hoy, antes de cerrar, el token de la Cloud API se regenera desde el usuario del sistema con una cita en el calendario para el 10 de mayo y con el procedimiento escrito (el manual que el Día 81 ensaya), y el dominio pasa a una cuenta del negocio con doble factor por aplicación y con Valeria como segunda administradora. Esta semana, la clave de Claude se rota a dos claves en dos espacios de trabajo (produccion con tope de $10.50$ USD y evaluacion con tope de $2$ USD) y la antigua se revoca; la integración de Notion se vuelve a crear desde la cuenta del negocio y se conecta solo a las dos bases; y las tres consolas con SMS pasan a una aplicación de códigos, con los códigos de recuperación en el gestor. En treinta días, los alcances sobrantes de Gmail, Postgres, Make y Slack, el segundo administrador en las diez consolas que no lo tienen, la revocación de Twilio y la limpieza de las exportaciones antiguas. Y una regla nueva en el calendario de gobierno: cada revisión trimestral recorre la Tabla 1 y escribe la fecha de rotación de lo que se rotó.
Consideraciones finales
La auditoría de hoy no encontró ningún secreto filtrado ni ninguna puerta forzada, y aun así produjo catorce hallazgos. Esa es la diferencia entre una auditoría y una investigación: la investigación busca lo que pasó y la auditoría busca lo que puede pasar, y lo mide antes. Una clave única para producción y evaluación no ha hecho daño todavía; lo hará el día en que una evaluación con mil casos agote el tope de gasto o el día en que haya que revocarla por una filtración en el entorno de pruebas y se lleve por delante la producción. Un dominio en una cuenta personal sin doble factor no ha hecho daño todavía; el día que lo haga, no habrá servidor que restaurar porque los webhooks irán a otro sitio.
Hay dos cosas que esta entrada no hace y que conviene decir. No sustituye a un profesional de seguridad: la lista de controles es la de CIS v8.1 reducida a lo que una panadería puede operar, no la completa, y cuando el negocio maneje datos de salud (la clínica del Día 7) o pagos con tarjeta, la auditoría necesita a alguien que sepa más que este cuaderno. Y no convierte el simulacro en garantía: $170$ minutos un martes tranquilo, con Valeria descansada y el cronómetro a la vista, no son $170$ minutos un viernes a las seis de la tarde con el teléfono sonando; por eso el RTO objetivo tiene $70$ minutos de holgura y por eso el simulacro se repite en octubre. El libro que acompaña este día es Site Reliability Engineering (Beyer et al., 2016), y en particular su capítulo sobre integridad de datos, cuya frase sirve de resumen: nadie quiere copias de seguridad, lo que todos quieren son restauraciones.
Una llave que no está en el inventario es una llave que nadie va a cambiar, y una copia que nunca se ha restaurado con el cronómetro en marcha es una promesa, no un plan.
Conclusiones clave
Inventario y rotación
- El inventario de credenciales se lee de los mismos JSON que el inventario de flujos ($41$ referencias bajo $24$ nombres en $23$ flujos) y se completa a mano con las $15$ llaves de infraestructura y consolas que ningún flujo nombra: $26$ en total, $3$ en cuentas personales.
- Los nombres no son las llaves: Postgres vive bajo $8$ nombres para $4$ roles y Claude bajo $5$ nombres para una sola clave, que la pista
a7Qxde la consola delata en producción y en evaluación a la vez. - Ninguna de las $26$ credenciales se ha rotado; con la política por tipo ($90$, $180$ y $365$ días) hay $1$ vencida (el dominio, $2,441$ días), $3$ por vencer en $30$ días y $7$ que caducan antes de la revisión trimestral, con el token de la Cloud API del 19 de mayo como la cita que nadie tenía apuntada.
Permisos y cuentas
- El exceso de permisos es una diferencia de conjuntos, $C \setminus N$: $35$ puertas abiertas de más en $14$ credenciales, con un índice medio de mínimo privilegio de $0.50$; Notion aporta $12$ (catorce páginas para dos bases), Postgres TL $4$ (escritura en flujos que solo leen) y Google Sheets $0$.
- De las $12$ consolas, $9$ son del negocio, $9$ tienen doble factor, $3$ lo tienen por SMS, $4$ guardan códigos de recuperación y solo $2$ tienen segundo administrador: sin Valeria, el negocio entra en $2$ de $12$; el dominio tiene $0$ de $5$ controles y es la llave que abre todas las demás.
- Los $14$ hallazgos llevan $R = P \times I$, severidad y fecha calculada ($2$ críticos para mañana, $4$ altos para el 28 de abril); el detector de patrones y entropía revisó $78$ archivos y $3,056$ filas de registro, encontró $20$ coincidencias, $15$ de ejemplo y $5$ a revisar, y ningún secreto vivo.
Restauración medida
- El ecosistema entero se reconstruyó en un servidor nuevo, sin mirar el viejo, en $170$ minutos frente a $125$ planeados y un RTO de $240$; el DNS con TTL de $3,600$ segundos explicó el $57,%$ del retraso y los $23$ workflows importados llegaron desactivados, como documenta n8n.
- Si
N8N_ENCRYPTION_KEYse hubiera perdido, las $21$ credenciales de n8n ($13$ secretos distintos) habrían tenido que reemitirse: $343$ minutos, $103$ más que el RTO; por eso la clave vive fuera del servidor con su huella al lado. - La pérdida esperada de encargos es $9.37$ con copia diaria, $2.10$ cada seis horas y $0.37$ con copia horaria en horario de atención (fórmula, integral y simulación coinciden); la copia horaria cuesta $1.47$ GB y se adopta, y el RPO formal se queda en $24$ horas porque un objetivo se promete, no se desea.
Mañana, en el Día 81: Proyecto integrador III: manuales de operación (runbooks) y continuidad. El token de la Cloud API que hoy quedó marcado en rojo caducará un viernes a las seis de la tarde en el ensayo de mesa, y nadie sabrá qué hacer; Don Ernesto preguntará por qué en el obrador hay un papel pegado que dice qué hacer si se va la luz y aquí no, y Valeria escribirá los ocho manuales de Trigo Lento con síntoma, diagnóstico, acción y verificación, el modo manual de la libreta y el plan de continuidad que convierte el simulacro de hoy en rutina.
Referencias
- Center for Internet Security (2024). CIS Critical Security Controls, Version 8.1. https://www.cisecurity.org/controls
- Temoshok, D., Proud-Madruga, D., Choong, Y.-Y., Galluzzo, R., Gupta, S., LaSalle, C., Lefkovitz, N. y Regenscheid, A. (2025). NIST Special Publication 800-63B-4: Digital Identity Guidelines, Authentication and Authenticator Management. National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63B-4
- Swanson, M., Bowen, P., Phillips, A. W., Gallup, D. y Lynes, D. (2010). NIST Special Publication 800-34 Rev. 1: Contingency Planning Guide for Federal Information Systems. National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-34r1
- OWASP Foundation (2026). Secrets Management Cheat Sheet. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- n8n (2026). Documentación: CLI commands (
export:workflow,export:credentials,import:workflow,import:credentials) y variable de entornoN8N_ENCRYPTION_KEY. https://docs.n8n.io - Anthropic (2026). Workspaces: límites de gasto y claves por espacio de trabajo. Claude Platform Documentation. https://platform.claude.com/docs/en/manage-claude/workspaces
- Microsoft Learn (2026). LinkedIn Marketing API: Programmatic Refresh Tokens. https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens
- Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (Eds.) (2016). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media. (Capítulo 26, “Data Integrity”.)
- Shannon, C. E. (1948). A Mathematical Theory of Communication. The Bell System Technical Journal, 27(3), 379-423. https://doi.org/10.1002/j.1538-7305.1948.tb01338.x

