Mi cuenta de AWS es pequeña. Dos Lambdas, un HTTP API y unos cuantos buckets de S3 detrás de CloudFront para este sitio y un par más. La factura son centavos al mes. Casi siempre entro a la consola solo cuando algo me manda un correo.

El 5 de noviembre por fin abrí un aviso que llevaba más de una semana en la campanita: “[Action required] Update CloudWatch Logs IAM policies by April 26, 2027 to avoid losing access”. Abril de 2027 está lejos, y lo primero que pensé fue archivarlo como los demás. Después sí lo leí.

Qué dice la alerta

CloudWatch Logs tiene dos tipos de ARN. Un log group es arn:aws:logs:<region>:<account>:log-group:<name>. Un stream dentro de él es ...:log-group:<name>:log-stream:<stream>, y el atajo de siempre, ...:log-group:<name>:*, quiere decir todos los streams de ese grupo.

Hoy, si una política da una acción de log group como logs:CreateLogGroup sobre ese ARN de stream con :*, AWS la deja pasar. Desde el 26 de abril de 2027 ya no. Las acciones de grupo necesitan el ARN del grupo. Solo cuatro acciones se quedan a nivel de stream: PutLogEvents, GetLogEvents, CreateLogStream y DeleteLogStream.

El aviso dice que mi cuenta tiene políticas así. No dice cuáles. El Health dashboard tiene una pestaña de recursos afectados para eso, pero la página del evento no me cargó, y el API de Health pide un plan de soporte pago. Entonces me tocó buscarlas a mí.

Encontrando la única política

aws iam get-account-authorization-details saca todos los usuarios, grupos, roles y políticas en una sola llamada. En mi cuenta fue un JSON de 17 MB, casi todo políticas administradas por AWS. Le escribí un chequeo pequeño en Python: statements Allow, con cualquier acción de logs que no sea una de esas cuatro, sobre un recurso con formato de stream.

Yo esperaba una lista. Salió una sola mía, la política inline de mi Lambda de alertas de costos, la que me manda la factura de AWS a Telegram:

Action = [
  "logs:CreateLogGroup",
  "logs:CreateLogStream",
  "logs:PutLogEvents"
]
Resource = "arn:aws:logs:${var.aws_region}:*:log-group:/aws/lambda/${local.lambda_function_name}:*"

Y la función nunca necesitó CreateLogGroup. El mismo stack de Terraform ya crea el log group con 7 días de retención, y la Lambda tiene depends_on sobre él. El arreglo fue quitar esa acción y armar el ARN del stream desde el recurso real, que de paso pone mi account ID donde estaba el *:

Action = [
  "logs:CreateLogStream",
  "logs:PutLogEvents"
]
Resource = "${aws_cloudwatch_log_group.cost_alert_lambda.arn}:*"

También salieron dos políticas service-linked administradas por AWS, una de EKS y otra de RDS. Esas las actualiza AWS, no yo. Un PR, un plan con un solo cambio in-place, merge, y el CI lo aplicó.

Ya que estaba ahí

Arreglar una política y cerrar la pestaña no me sonó, así que hice una revisión de solo lectura de toda la cuenta. Las 17 regiones habilitadas, solo llamadas describe, list y get, todo guardado en JSON y revisado contra el benchmark de CIS y las buenas prácticas de la misma AWS.

La primera lectura de la consola también estaba mal. El encabezado muestra el nombre de la cuenta, y el mío termina en _root_account. Parecía una sesión de root. No era. Yo estaba con mi usuario IAM normal con MFA, y el credential report decía que root no entraba desde abril de 2024. Buena noticia, pero tocó verificarlo antes de creerlo.

La parte de datos estaba bien casi toda. Todos los buckets cifrados, los sitios servidos por CloudFront con origin access control, ningún security group abierto al mundo, root con MFA y sin access keys. Y las alertas de presupuesto sí me llegan al correo y a Telegram.

Lo malo era todo lo que tiene que ver con saber qué pasa en la cuenta:

  • Ningún trail de CloudTrail. Ni en us-east-2, ni en us-east-1, ni en ninguna de las 17 regiones.
  • Nada de GuardDuty, ni IAM Access Analyzer, ni Config, ni Security Hub.
  • Las dos Lambdas tenían sus secretos en texto plano como variables de entorno. El token del bot de Telegram, y el client secret y el refresh token de Spotify para el widget de “qué estoy escuchando” de este sitio.
  • La Lambda de Spotify seguía en nodejs20.x. Eso explicaba un año de correos de “Node.js 20.x end-of-life” que yo venía archivando.

Si mañana se filtra una de mis access keys, el único registro sería el event history de 90 días, y nada me avisaría.

Tres PRs más

Secretos a SSM. El stack de alertas de costos ya guardaba el token de Telegram en SSM como SecureString, y la Lambda ya tenía ssm:GetParameter sobre él. Simplemente no lo usaba, porque Terraform también le pasaba el valor directo en el entorno. Ahora el entorno solo tiene el nombre del parámetro. La función lee el valor en el cold start, con descifrado, y lo deja en memoria. Para Spotify agregué dos SecureStrings y una política inline solo para esos dos ARN. El client ID quedó como variable normal porque no es un secreto. Las dos usan la llave administrada aws/ssm, así que no hubo que escribir política de KMS.

Node 24, no 22. AWS depreca nodejs22.x más o menos en abril de 2027. Pasarme a 22 era volver a recibir el mismo correo en seis meses. El handler ya era async, y eso importa porque el runtime de Node 24 en Lambda ya no soporta handlers con callback.

Un stack base. Un stack nuevo de Terraform que se llama aws-security:

resource "aws_cloudtrail" "account" {
  name           = "johnylab-account-trail"
  s3_bucket_name = aws_s3_bucket.cloudtrail.id

  is_multi_region_trail         = true
  include_global_service_events = true
  enable_log_file_validation    = true
}

El bucket detrás tiene Block Public Access, ownership bucket-owner-enforced, SSE-S3, versionamiento, un deny para todo lo que no sea TLS, una política de escritura limitada a este trail con aws:SourceArn, y expiración a 365 días. Al lado, GuardDuty y Access Analyzer en us-east-2, donde corre todo, y en us-east-1, que es donde caen los eventos de IAM, STS y CloudFront.

Un detalle del CI que casi me muerde

Mi pipeline en Gitea hace plan en cada PR y aplica cuando hago merge a main. Antes de hacer merge de los tres PRs volví a leer el workflow. Los runs de push comparten un grupo de concurrencia con cancel-in-progress: true, y el job de apply decide qué stacks aplicar con git diff HEAD~1..HEAD.

O sea que si hago merge de dos PRs seguidos, el segundo run cancela el apply del primero, de pronto a la mitad, y después solo aplica su propio stack. El primer PR no llegaría a AWS hasta que el chequeo diario de drift se quejara. Les hice merge de a uno y esperé el verde entre cada uno. Arreglar el workflow quedó en la lista.

¿Esto me dispara una factura de centavos?

Eso fue lo siguiente que me pregunté, así que lo revisé en vez de adivinar:

Pieza Costo
Trail de CloudTrail $0. La primera copia de los management events en cada región es gratis, y yo no tenía otro trail.
Bucket de S3 para los logs Entre $0,10 y $0,20 al mes, casi todo en PUTs. La validación de logs escribe un digest cada hora en cada región, unos 12.000 archivos al mes.
GuardDuty Gratis 30 días, después se cobra por uso. Para una cuenta así de quieta deberían ser centavos.
Access Analyzer $0 para el analyzer de acceso externo.
SSM y la llave aws/ssm $0 con este volumen.

O sea que la factura pasa de unos $0,10 a algo por debajo de 1 dólar. GuardDuty tiene una página de Usage que muestra el costo proyectado durante el trial. La voy a revisar antes de que se acabe en vez de confiar en mi cálculo.

Con qué me quedo

En el trabajo hay un equipo de seguridad y un trail para toda la organización. En mi cuenta personal estaba yo, archivando correos. La cuenta pequeña es justo la que nadie más está mirando, entonces necesita la base de seguridad más, no menos.

La alerta en sí era un arreglo de cinco minutos. Lo que valía la pena arreglar era lo que estaba al lado.

Lo que sigue es IAM: menos usuarios con permisos de admin y nada de access keys viejas por ahí. Eso va en su propio post.