An AWS alert about April 2027 showed me my account had no audit trail
Leer en español →My AWS account is small. Two Lambdas, one HTTP API, and a few S3 buckets behind CloudFront for this site and a couple of others. The bill is a few cents a month. I mostly open the console when something emails me.
On November 5 I finally opened a notice that had been sitting under the bell icon for over a week: “[Action required] Update CloudWatch Logs IAM policies by April 26, 2027 to avoid losing access”. April 2027 is far away, and my first reaction was to archive it like the others. Then I actually read it.
What the alert says
CloudWatch Logs has two kinds of ARNs. A log group is arn:aws:logs:<region>:<account>:log-group:<name>. A stream inside it is ...:log-group:<name>:log-stream:<stream>, and the usual shorthand ...:log-group:<name>:* means every stream in that group.
Today, if a policy grants a log group action like logs:CreateLogGroup on that :* stream ARN, AWS lets it through. From April 26, 2027 it won’t. Group actions need the group ARN. Only four actions stay at the stream level: PutLogEvents, GetLogEvents, CreateLogStream and DeleteLogStream.
The notice says my account has statements like that. It does not say which ones. The Health dashboard has an affected resources tab for this, but the event page would not load for me, and the Health API needs a paid support plan. So I went looking myself.
Finding the one policy
aws iam get-account-authorization-details dumps every user, group, role and policy in one call. For my account that was a 17 MB JSON file, most of it AWS-managed policies. I wrote a small Python check over it: Allow statements, with any logs action other than those four, on a resource in the stream format.
I expected a list. I got one hit of my own, the inline policy of my cost-alert Lambda, the one that sends my AWS bill to Telegram:
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:${var.aws_region}:*:log-group:/aws/lambda/${local.lambda_function_name}:*"
The function never needed CreateLogGroup in the first place. The same Terraform stack already creates the log group with 7 days of retention, and the Lambda depends_on it. So the fix was to delete the action and build the stream ARN from the real resource, which also puts my account ID where the * was:
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "${aws_cloudwatch_log_group.cost_alert_lambda.arn}:*"
Two AWS-managed service-linked policies also matched, one for EKS and one for RDS. Those are AWS’s to update, not mine. One PR, a plan with one in-place change, merged, applied by CI.
While I was in there
Fixing one policy and closing the tab felt wrong, so I ran a read-only pass over the whole account. All 17 enabled regions, only describe, list and get calls, everything saved to JSON and checked against the CIS benchmark and AWS’s own best practices.
The first read of the console was wrong too. The header shows the account name, and mine ends in _root_account. It looked like a root session. It wasn’t. I was signed in as my normal IAM user with MFA, and the credential report said root had not logged in since April 2024. Good news, but I had to check before I believed it.
Most of the data side was fine. Every bucket encrypted, the sites served through CloudFront with origin access control, no security group open to the world, root with MFA and no access keys. And the budget alerts really do reach my email and Telegram.
The bad part was everything about knowing what happens in the account:
- No CloudTrail trail. Not in us-east-2, not in us-east-1, not in any of the 17 regions.
- No GuardDuty, no IAM Access Analyzer, no Config, no Security Hub.
- Both Lambdas kept their secrets as plaintext environment variables. The Telegram bot token, and the Spotify client secret and refresh token for the now-playing widget on this site.
- The Spotify Lambda was still on
nodejs20.x. That explained a year of “Node.js 20.x end-of-life” emails I had been archiving.
If one of my access keys leaked tomorrow, the only record would be the 90-day event history, and nothing would tell me.
Three more PRs
Secrets to SSM. The cost-alert stack already stored the Telegram token in SSM as a SecureString, and the Lambda already had ssm:GetParameter on it. It just never used it, because Terraform also passed the value straight into the environment. Now the environment only holds the parameter name. The function reads the value at cold start with decryption and keeps it in memory. For Spotify I added two SecureStrings and an inline policy for exactly those two ARNs. The client ID stayed a normal variable because it is not a secret. Both use the AWS-managed aws/ssm key, so there was no KMS policy to write.
Node 24, not 22. AWS deprecates nodejs22.x around April 2027. Going to 22 would get me the same email again in six months. The handler was already async, which matters because the Node 24 runtime on Lambda drops callback-style handlers.
A baseline stack. A new Terraform stack called 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
}
The bucket behind it has Block Public Access, bucket-owner-enforced ownership, SSE-S3, versioning, a TLS-only deny, a write policy scoped to this one trail with aws:SourceArn, and a 365-day expiry. Next to it, GuardDuty and Access Analyzer in us-east-2, where everything runs, and in us-east-1, where the IAM, STS and CloudFront events land.
A CI detail that almost bit me
My Gitea pipeline plans every PR and applies on merge to main. Before merging the three PRs I read the workflow again. Push runs share a concurrency group with cancel-in-progress: true, and the apply job decides which stacks to apply from git diff HEAD~1..HEAD.
So if I merge two PRs back to back, the second run cancels the first apply, maybe halfway through, and then only applies its own stack. The first PR would never reach AWS until the daily drift check complained. I merged them one at a time and waited for green between each. Fixing the workflow itself is on the list.
Does this blow up a bill of a few cents?
That was my next question, so I checked instead of guessing:
| Piece | Cost |
|---|---|
| CloudTrail trail | $0. The first copy of management events in each region is free, and I had no other trail. |
| S3 bucket for the logs | About $0.10 to $0.20 a month, mostly PUT requests. Log file validation writes a digest every hour in every region, around 12,000 files a month. |
| GuardDuty | Free for 30 days, then usage-based. For an account this quiet it should be cents. |
| Access Analyzer | $0 for the external access analyzer. |
SSM and the aws/ssm key |
$0 at this volume. |
So the bill goes from about $0.10 to somewhere under $1. GuardDuty has a Usage page that shows the projected cost during the trial. I will look at it before the trial ends instead of trusting my own estimate.
What I keep from this
At work there is a security team and an org-wide trail. In my personal account there was me, archiving emails. The small account is the one where nobody else is watching, so it needs the baseline more, not less.
The alert itself was a five-minute fix. The part worth fixing was what was sitting next to it.
Next is IAM: fewer users with admin rights, and no old access keys lying around. That one gets its own post.