AWS Builder Center
La credencial que nadie revocó: alertas de seguridad en Slack con CloudTrail y Lambda

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:
  1. 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.
  1. 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.
  1. 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.
  1. Si coincide, se invoca la Lambda. CloudWatch Logs la llama de forma asíncrona y le pasa un lote de eventos comprimido.
  1. La función decide qué tan grave es. Decodifica cada evento, le asigna una criticidad y arma un mensaje legible.
  1. 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-dev  IP:  192.0.2.0  · Región:  us-east-1  · Hora:  2026-11-05T22:14:06Z  Pará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:
  1. 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.
  1. Un topic SNS estándar conectado a un canal de Slack desde Amazon Q Developer in chat applications.
  1. Un rol de ejecución para la función con permiso de  sns:Publish  sobre ese topic,  sqs:SendMessage  sobre la cola de respaldo y los permisos básicos para escribir sus propios logs.
  1. 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 lote
level = ACCOUNTS.get(ct.get("recipientAccountId"), DEFAULT_LEVEL)

if ct.get("eventSource") in SENSITIVE_SOURCES: # rds, dynamodb, s3, backup, kms, secretsmanager, iam
level += 1
if identity.get("type") == "IAMUser":
level += 1
if identity.get("type") == "Root" or ct.get("eventName") in ALWAYS_HIGH:
level = HIGH
level = 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

Si preferís los conceptos sin comandos, la versión más simple está en  dev.to  y en Medium.
Plain text
━━━━━━━━━━━━━━━━━━━━
🟧 dev.luchops
Cloud & DevOps, explicado sin vueltas.
━━━━━━━━━━━━━━━━━━━━

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article