
La credencial que nadie revocó: alertas de seguridad en Slack con CloudTrail y Lambda
Cómo funciona y cómo implementar un sistema que te avisa en minutos cuando alguien usa una access key, asume un rol o destruye recursos en tu organización de AWS.
¿Te interesa saber cuándo alguien accede de manera no autorizada mediante una access key o asumiendo un rol? ¿O cuándo alguien que ya no trabaja con ustedes usa una credencial que tenía clonada en su notebook para destruir todo?
La buena noticia es que AWS ya registra cada una de esas acciones. La mala es que, en la mayoría de los equipos, nadie las mira mientras pasan: uno se entera cuando algo deja de funcionar. En este artículo vamos a ver cómo cerrar esa brecha con servicios nativos de AWS, primero entendiendo cómo funciona todo por dentro y después implementándolo paso a paso, con los comandos oficiales y el código justo y necesario.
Cada llamada a tu AWS deja una huella
Todo empieza en CloudTrail. Cada vez que alguien (o algo) llama a la API de AWS, CloudTrail guarda un evento que cuenta la historia completa: qué se hizo (
eventName ), en qué servicio ( eventSource ), cuándo ( eventTime ), desde qué IP y región ( sourceIPAddress , awsRegion ), en qué cuenta ( recipientAccountId ), si fue una lectura o una escritura ( readOnly ) y si falló ( errorCode ).Pero el dato más valioso para este caso está en otro lado: el bloque
userIdentity , y en particular su campo type , que dice con qué tipo de credencial se hizo la llamada.userIdentity.type | Qué significa | Por qué importa |
|---|---|---|
Root | Credenciales de la cuenta root | Casi nunca debería usarse |
IAMUser | Access key o password de un IAM user | Es de larga duración: sobrevive al offboarding si nadie la revoca |
AssumedRole | Credencial temporal obtenida al asumir un rol | Lo habitual para personas (SSO) y workloads |
AWSAccount | Otra cuenta de AWS asumió un rol tuyo | Indica acceso entre cuentas |
AWSService | Un servicio de AWS actuó en tu nombre | Normalmente no requiere alerta |
Con esta tabla en mente, cada escenario del principio tiene una firma reconocible. La access key olvidada en una notebook aparece como
IAMUser . Un tercero que entra asumiendo un rol tuyo desde su cuenta aparece como AWSAccount . Y un login con root aparece, justamente, como Root .Vale aclarar algo: AWS ya recomienda que las personas usen credenciales temporales mediante federación, y que las access keys se actualicen cuando alguien deja la empresa. El problema es que ese paso del offboarding a veces se olvida. Este sistema es la red de seguridad para ese momento.
De una llamada a la API a un mensaje en Slack
Antes de entrar en los detalles, vale la pena ver el recorrido completo:

Imaginemos que alguien borra una base de datos de producción usando una access key. A partir de ese momento, pasa lo siguiente:
- CloudTrail registra el evento. Si la organización tiene una organization trail (Control Tower crea una automáticamente), ese registro cubre la cuenta de management y todas las cuentas miembro por igual.
- El evento viaja a CloudWatch Logs. La trail lo entrega a un log group en la cuenta de management en un promedio de unos 5 minutos. AWS aclara que ese tiempo no está garantizado, y que los eventos de más de 256 KB no se envían.
- Los subscription filters lo evalúan. Son reglas enganchadas al log group que comparan cada evento contra un patrón. Si no coincide, el recorrido termina ahí, sin invocaciones ni costo de cómputo.
- Si coincide, se invoca la Lambda. CloudWatch Logs la llama de forma asíncrona y le pasa un lote de eventos comprimido.
- La función decide qué tan grave es. Decodifica cada evento, le asigna una criticidad y arma un mensaje legible.
- El mensaje llega a Slack. La función lo publica en un topic SNS y Amazon Q Developer lo entrega en el canal del equipo.
¿Y si algo falla en el camino? En ese caso, Lambda reintenta la ejecución dos veces más (esperando uno y dos minutos). Si aun así no lo logra, el evento termina en una cola SQS de respaldo y una alarma avisa al equipo de operaciones por un canal separado. Así, ninguna alerta se pierde en silencio.
Qué viaja en cada paso
Ver los datos concretos ayuda a entender qué puede hacer la función con ellos. Este es, en forma abreviada, el evento que CloudTrail generaría en nuestro ejemplo (el formato está basado en el ejemplo de la documentación de RDS):
Plain text
{"userIdentity": {"type": "IAMUser","arn": "arn:aws:iam::123456789012:user/ex-dev","userName": "ex-dev"},"eventTime": "2026-11-05T22:14:06Z","eventSource": "rds.amazonaws.com","eventName": "DeleteDBInstance","awsRegion": "us-east-1","sourceIPAddress": "192.0.2.0","requestParameters": { "dBInstanceIdentifier": "prod-db" },"recipientAccountId": "123456789012"}
Sin embargo, la Lambda no lo recibe así. CloudWatch Logs le entrega un objeto
{"awslogs": {"data": "..."}} , donde data viene comprimido con gzip y codificado en base64. Una vez decodificado, contiene información del log group ( owner , logGroup , logStream , subscriptionFilters , messageType ) y una lista logEvents , en la que el campo message de cada elemento es el evento de CloudTrail completo.Finalmente, después de que la función hace su trabajo, esto es lo que ve el equipo en Slack:
🔴 ALTA · prod · DeleteDBInstance Borrado de recurso Credencial: IAM user (credencial de larga duración) Quién:arn:aws:iam::123456789012:user/ex-devIP:192.0.2.0· Región:us-east-1· Hora:2026-11-05T22:14:06ZParámetros:{"dBInstanceIdentifier": "prod-db"}
Quien esté de guardia sabe qué pasó, dónde, quién lo hizo y con qué credencial, sin abrir la consola.
Lo que este sistema puede ver por vos
Ahora que sabemos qué información hay en cada evento, la pregunta es qué situaciones podemos detectar con ella. Estas son las más útiles:
Situación | Cómo se ve en el evento |
|---|---|
Un ex integrante usa una access key que nadie revocó | userIdentity.type = IAMUser en una escritura exitosa |
Una access key filtrada se usa desde otro lugar | IAMUser desde una sourceIPAddress que no reconocés |
Alguien deja una puerta abierta para volver | CreateAccessKey , CreateLoginProfile , UpdateAssumeRolePolicy , AttachRolePolicy |
Alguien usa la cuenta root | userIdentity.type = Root , sin invokedBy |
Alguien entra a la consola sin MFA | ConsoleLogin con MFAUsed distinto de Yes |
Alguien intenta borrar sus huellas | Escrituras en CloudTrail: StopLogging , DeleteTrail , UpdateTrail |
Se borran bases, backups o llaves | DeleteDBInstance , DeleteTable , DeleteRecoveryPoint , ScheduleKeyDeletion |
Se sacan datos sin hacer ruido | ModifyDBSnapshotAttribute , que comparte un snapshot con otra cuenta o lo hace público |
Un error humano, como un destroy en la cuenta equivocada | Delete* o Terminate* en producción |
Hay un caso que conviene mencionar aparte: el momento exacto en que otra cuenta asume uno de tus roles. Ese
AssumeRole aparece con userIdentity.type = AWSAccount en la cuenta dueña del rol. Se puede sumar a la detección, aunque hay que tener en cuenta que también va a alertar los accesos entre cuentas legítimos.Manos a la obra
Lo que necesitás antes de empezar
Para armar todo esto hacen falta cuatro cosas:
- Una organization trail que envíe a CloudWatch Logs. Si usás Control Tower, ya la tenés; solo necesitás el nombre de su log group en la cuenta de management.
- Un topic SNS estándar conectado a un canal de Slack desde Amazon Q Developer in chat applications.
- Un rol de ejecución para la función con permiso de
sns:Publishsobre ese topic,sqs:SendMessagesobre la cola de respaldo y los permisos básicos para escribir sus propios logs.
- Permiso para que CloudWatch Logs invoque la función, limitado al log group de la trail.
El corazón de la función
La función es más simple de lo que parece, y no necesita nada más que el SDK de AWS. Su trabajo se resume en tres pasos: decodificar, clasificar y publicar. Veamos el código esencial de cada uno (en Python, simplificado).
Lo primero es abrir el lote que manda CloudWatch Logs. Es la única parte específica de los subscription filters:
Plain text
payload = json.loads(gzip.decompress(base64.b64decode(event["awslogs"]["data"])))for log_event in payload["logEvents"]:ct = json.loads(log_event["message"]) # el evento de CloudTrail
Con el evento en la mano, llega el momento de decidir qué tan grave es. Un modelo simple funciona muy bien: cada cuenta tiene una criticidad base, que sube un nivel si el evento toca un servicio sensible y otro más si se usó una credencial de larga duración. Además, algunos eventos son críticos siempre, sin importar dónde ocurran:
Plain text
identity = ct.get("userIdentity") or {} # puede venir null: no romper el lotelevel = ACCOUNTS.get(ct.get("recipientAccountId"), DEFAULT_LEVEL)if ct.get("eventSource") in SENSITIVE_SOURCES: # rds, dynamodb, s3, backup, kms, secretsmanager, iamlevel += 1if identity.get("type") == "IAMUser":level += 1if identity.get("type") == "Root" or ct.get("eventName") in ALWAYS_HIGH:level = HIGHlevel = min(level, HIGH)
Conviene que
ACCOUNTS salga de configuración (por ejemplo, una variable de entorno con un JSON de cuenta y criticidad base), así se pueden sumar cuentas sin desplegar código nuevo. Por su parte, ALWAYS_HIGH agrupa los eventos que borran evidencia o hacen irrecuperable el dato: ConsoleLogin (por el filtro solo llegan los logins sin MFA), StopLogging , DeleteTrail , UpdateTrail , PutEventSelectors , ScheduleKeyDeletion , DisableKey , ModifyDBSnapshotAttribute , ModifyDBClusterSnapshotAttribute , DeleteRecoveryPoint , DeleteBackupVault y PutBackupVaultAccessPolicy .Por último, solo queda publicar el mensaje. Para que Amazon Q Developer lo muestre bien en Slack, tiene que seguir su formato de custom notifications:
version debe ser "1.0" , source debe ser "custom" y description es obligatoria, con un máximo de 8000 caracteres.Plain text
notification = {"version": "1.0","source": "custom","content": {"textType": "client-markdown", "title": title, "description": description},"metadata": {"threadId": ct.get("eventID", "")},}sns.publish(TopicArn=TOPIC_ARN, Message=json.dumps(notification, ensure_ascii=False))
El resto del código es formato: armar
title y description con la cuenta, la acción, el ARN del actor, el tipo de credencial, la IP, la región, la hora y los requestParameters recortados.Conectar las piezas
Con la función desplegada, quedan dos conexiones. La primera es darle permiso a CloudWatch Logs para invocarla. El comando está adaptado de la documentación de subscription filters:
Plain text
aws lambda add-permission \--function-name "my-security-alerts" \--statement-id "my-security-alerts" \--principal "logs.amazonaws.com" \--action "lambda:InvokeFunction" \--source-arn "arn:aws:logs:us-east-1:123456789012:log-group:my-org-trail-log-group:*" \--source-account "123456789012"
La segunda es crear el subscription filter que conecta el log group con la función, con
aws logs put-subscription-filter . Ese comando recibe el nombre del log group ( --log-group-name ), un nombre para el filtro ( --filter-name ), el patrón ( --filter-pattern ) y el ARN de la función ( --destination-arn ). Para el patrón no hace falta partir de cero: el control CloudWatch.1 de Security Hub publica el patrón oficial para detectar el uso de root, y los controles CloudWatch.3 a CloudWatch.14 cubren los demás casos clásicos.Dos filtros, dos preguntas
Acá está la decisión de diseño más importante de todo el sistema: qué dejar pasar.
La tentación natural es filtrar por actor, es decir, alertar solo sobre lo que hacen las personas del equipo (por ejemplo, los usuarios de SSO) para no llenar el canal con lo que hacen los pipelines y los controladores de Kubernetes. El problema es que un IAM user con una access key no entra por SSO. Un filtro así deja afuera, justamente, la credencial que sobrevive cuando alguien se va.
Por suerte, cada log group admite hasta dos subscription filters, y eso permite separar el problema en dos preguntas.
La primera: ¿quién está actuando? En este filtro alcanza con que se cumpla cualquiera de estas condiciones:
Condición | Campos del evento | Base |
|---|---|---|
Uso de root que no viene de un servicio | userIdentity.type , userIdentity.invokedBy , eventType | CloudWatch.1 |
Login a consola sin MFA | eventName , additionalEventData.MFAUsed , responseElements.ConsoleLogin | CloudWatch.3 |
Cualquier escritura en CloudTrail | eventSource igual a cloudtrail.amazonaws.com , con readOnly en falso | CloudWatch.5, ampliado |
Cualquier escritura exitosa de un IAM user | userIdentity.type igual a IAMUser , con readOnly en falso y sin errorCode | Credenciales de larga duración |
Cambios de IAM que crean persistencia | eventName en CreateAccessKey , CreateLoginProfile , UpdateAssumeRolePolicy , AttachRolePolicy , PutRolePolicy , CreateUser | CloudWatch.4, acotado |
La segunda: ¿qué se está destruyendo? Este filtro, en cambio, exige que se cumplan todas estas condiciones a la vez:
Condición | Campos del evento |
|---|---|
La llamada fue exitosa | Sin errorCode |
No la hizo un servicio de AWS en tu nombre | Sin userIdentity.invokedBy |
No viene de tus automatizaciones | userIdentity.arn distinto del prefijo de tus roles de automatización |
Es una acción destructiva | eventName que empiece con Delete o Terminate , o alguna de las acciones irreversibles de la sección de expansión |
Al escribir los patrones, tené presente que cada uno tiene un máximo de 1024 caracteres. Si no te alcanza, toca priorizar, porque no se pueden agregar más de dos filtros al mismo log group.
Un detalle a considerar: si tenés IAM users legítimos para CI/CD, cada escritura suya va a disparar una alerta en el primer filtro. Lejos de ser un problema, es la señal de que esas credenciales deberían migrar a roles con credenciales temporales.
Probar antes de confiar
Antes de desplegar un patrón, conviene validarlo. El comando
test-metric-filter usa exactamente la misma sintaxis y no crea ningún recurso. La idea es armar eventos de ejemplo que deberían coincidir y otros que no (un borrado hecho por un rol de automatización, una llamada que falló con AccessDenied , una acción con invokedBy ) y verificar cada caso:Plain text
aws logs test-metric-filter \--filter-pattern 'TU_PATRON' \--log-event-messages 'EVENTO_JSON_1' 'EVENTO_JSON_2'
Que ninguna alerta se pierda
Como CloudWatch Logs invoca la función de forma asíncrona, si la función falla tres veces seguidas, Lambda descarta el evento. En un sistema de seguridad, eso es lo último que queremos. Por eso hacen falta dos piezas más.
La primera es un destino on failure: una cola SQS estándar (las FIFO no están soportadas) que recibe los eventos que agotaron los reintentos. El comando está adaptado de la documentación de destinos asíncronos:
Plain text
aws lambda update-function-event-invoke-config \--function-name my-security-alerts \--destination-config '{"OnFailure":{"Destination": "arn:aws:sqs:us-east-1:123456789012:security-alerts-failed"}}'
La segunda es una alarma sobre la propia función, que avisa apenas aparece un error:
Plain text
aws cloudwatch put-metric-alarm \--alarm-name my-security-alerts-errors \--namespace AWS/Lambda --metric-name Errors \--dimensions Name=FunctionName,Value=my-security-alerts \--statistic Sum --period 300 --evaluation-periods 1 \--threshold 1 --comparison-operator GreaterThanOrEqualToThreshold \--treat-missing-data notBreaching \--alarm-actions arn:aws:sns:us-east-1:123456789012:ops-alerts
Fijate que la alarma va a un topic distinto del de alertas de seguridad. La razón es simple: si el problema está en ese topic, el aviso no debería depender de él.
Cuando quieras ir más allá: bases de datos, llaves y backups
Una vez que el sistema funciona, el filtro de "qué se destruye" es el que más vale la pena ajustar a tu negocio. Estas son algunas acciones para sumar, con su nombre exacto de API:
Servicio | Acciones |
|---|---|
RDS / Aurora | DeleteDBInstance , DeleteDBCluster , DeleteDBSnapshot , DeleteDBClusterSnapshot , DeleteDBInstanceAutomatedBackup , ModifyDBSnapshotAttribute , ModifyDBClusterSnapshotAttribute , StopDBInstance , StopDBCluster , ModifyDBInstance , ModifyDBCluster |
DynamoDB | DeleteTable , DeleteBackup , UpdateTable |
KMS | ScheduleKeyDeletion , DisableKey |
AWS Backup | DeleteRecoveryPoint , DeleteBackupVault , DeleteBackupPlan , DeleteBackupVaultLockConfiguration , PutBackupVaultAccessPolicy , UpdateRecoveryPointLifecycle |
Secrets Manager | DeleteSecret |
Las que empiezan con
Delete ya entran con el comodín; las demás hay que listarlas una por una.Hay dos matices que conviene conocer. Por un lado,
ModifyDBInstance y ModifyDBCluster son acciones muy frecuentes, así que en vez de alertar sobre el evento completo es mejor filtrar por el parámetro que te preocupa, como desactivar la protección contra borrado. Como el nombre exacto de ese campo dentro de requestParameters no figura en una tabla de la documentación, lo más seguro es copiarlo de un evento real de tu CloudTrail.Por otro lado, en DynamoDB, borrar una tabla queda registrado por defecto, pero borrar ítems uno por uno no.
DeleteItem es un data event, y solo aparece si lo habilitás en la trail, con costo adicional.Lo que conviene tener a mano
Estos son los límites que más condicionan el diseño:
Límite | Valor | Fuente |
|---|---|---|
Largo del filter pattern | 1024 caracteres | API PutSubscriptionFilter |
Subscription filters por log group | 2 | API PutSubscriptionFilter |
Regex por filter pattern | 2 | API PutSubscriptionFilter |
Regex por log group | 5 | API PutSubscriptionFilter |
Tamaño máximo de evento enviado a CloudWatch Logs | 256 KB | CloudTrail |
Reintentos por error de la función | 2 | Lambda, invocación asíncrona |
title / description de la custom notification | 250 / 8000 caracteres | Amazon Q Developer |
Para saber si el sistema está sano, alcanza con mirar unas pocas métricas de Lambda.
Invocations muestra cuántos lotes pasan los filtros: un salto brusco puede ser un incidente o un patrón demasiado amplio. Errors debería estar siempre en cero. AsyncEventsDropped cuenta los eventos descartados sin ejecutar la función, y DestinationDeliveryFailures avisa si Lambda no pudo escribir en la cola de respaldo.Y si algo no anda, estos son los problemas más comunes:
Síntoma | Causa probable | Qué hacer |
|---|---|---|
LimitExceededException al crear el filtro | El log group ya tiene dos subscription filters | Combinar patrones o reemplazar uno existente |
InvalidParameterException al crear el filtro | Patrón mal formado o de más de 1024 caracteres | Validarlo con test-metric-filter y medir el largo |
La función nunca se invoca | Falta el permiso para logs.amazonaws.com o el --source-arn no coincide | Revisar la resource-based policy de la función |
Un borrado real no generó alerta | El actor quedó excluido por el filtro o el evento superó 256 KB | Buscar el evento en CloudTrail y probarlo contra el patrón |
Alertas duplicadas en Slack | Un reintento volvió a procesar el lote completo | Tolerar duplicados y usar eventID como threadId |
No aparece DeleteItem de DynamoDB | Es un data event y no se registra por defecto | Habilitar data events en la trail |
Cuánto cuesta tenerlo funcionando
En la mayoría de los casos, muy poco. El costo real depende de una sola pregunta: ¿tu trail ya envía eventos a CloudWatch Logs?
Componente | Cómo se cobra | Qué esperar |
|---|---|---|
CloudTrail, primera copia de management events | Gratis cuando se entrega a S3 | Sin costo nuevo |
CloudTrail a CloudWatch Logs | USD 0,25 por GB de entrega más la ingesta de CloudWatch Logs | El componente principal si todavía no lo tenés. Con Control Tower ya existe |
Lambda | USD 0,20 por millón de invocaciones y USD 0,0000166667 por GB-s (x86), con free tier de 1 millón de invocaciones y 400.000 GB-s por mes | Prácticamente cero |
SNS | Por publicación | Un mensaje por alerta |
Amazon Q Developer, custom notifications | Sin costo adicional | Cero |
Para tener una referencia concreta: en una organización de siete cuentas que ya tenía la trail enviando a CloudWatch Logs, se midieron 16 días de uso. Hubo 19 invocaciones sin errores, con una duración promedio de 448 ms y 128 MB de memoria, y 103 mensajes publicados en SNS. En total, menos de un centavo de dólar por mes.
Para cerrar
Este sistema no reemplaza a la prevención: para impedir acciones existen las SCP de AWS Organizations, que funcionan como guardrails de permisos. Tampoco reemplaza un buen offboarding, porque la mejor alerta es la que nunca se dispara porque la access key ya no existe (la información de last accessed de IAM ayuda a encontrar las que nadie usa).
Lo que sí hace es darte visibilidad en minutos sobre lo que más importa, en toda la organización y por un costo marginal. Y si hay una sola idea para llevarse, es esta: filtrá por quién actúa, pero también por qué se destruye.
Fuentes
Seguime en más plataformas
- Medium: medium.com/@lucianonicolasloyola
- YouTube: youtube.com/@dev.luchops
- TikTok: tiktok.com/@dev.luchops
- Instagram: instagram.com/dev.luchops
- LinkedIn: Luciano Nicolás Loyola
Plain text
━━━━━━━━━━━━━━━━━━━━🟧 dev.luchopsCloud & DevOps, explicado sin vueltas.━━━━━━━━━━━━━━━━━━━━
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article