Cloud Security · lesson 8 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
Also part of: Defensive Security AWS
Hunting in AWS CloudTrail Logs with Python
Search CloudTrail JSON with Python for root console logins, new access keys, IAM policy changes and public buckets, then build a sorted attack timeline.
By SkillByExample · Published
CloudTrail records every management API call in an AWS account. When something goes wrong, those records are the evidence. Each log file is plain JSON, so a few dozen lines of Python can search it for the events responders look at first and put them in order.
Tested on 2026-10-15 with Python 3.12.15 and the standard library only. The log below is a
sample written for this lesson, not from a real account. It follows the documented CloudTrail
record format, trimmed to the fields used here. The account and access key IDs are the examples
from AWS’s own documentation, and the IP addresses come from the RFC 5737 documentation ranges.
Save it as cloudtrail-sample.json:
{"Records": [
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:47:55Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEBACKUP0001", "arn": "arn:aws:iam::111122223333:user/backup-admin", "accountId": "111122223333", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "userName": "backup-admin"},
"eventSource": "s3.amazonaws.com", "eventName": "PutBucketAcl", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "aws-cli/2.31.0",
"requestParameters": {"bucketName": "acme-reports", "Host": "acme-reports.s3.us-east-1.amazonaws.com", "acl": "", "x-amz-acl": ["public-read"]},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T09:02:13Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEALICE0001", "arn": "arn:aws:iam::111122223333:user/alice", "accountId": "111122223333", "userName": "alice"},
"eventSource": "signin.amazonaws.com", "eventName": "ConsoleLogin", "awsRegion": "us-east-1",
"sourceIPAddress": "203.0.113.10", "userAgent": "Mozilla/5.0",
"requestParameters": null, "responseElements": {"ConsoleLogin": "Success"},
"additionalEventData": {"MFAUsed": "Yes"}, "eventType": "AwsConsoleSignIn", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:38:51Z",
"userIdentity": {"type": "Root", "principalId": "111122223333", "arn": "arn:aws:iam::111122223333:root", "accountId": "111122223333"},
"eventSource": "signin.amazonaws.com", "eventName": "ConsoleLogin", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "Mozilla/5.0",
"requestParameters": null, "responseElements": {"ConsoleLogin": "Success"},
"additionalEventData": {"MFAUsed": "No"}, "eventType": "AwsConsoleSignIn", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:41:07Z",
"userIdentity": {"type": "Root", "principalId": "111122223333", "arn": "arn:aws:iam::111122223333:root", "accountId": "111122223333"},
"eventSource": "iam.amazonaws.com", "eventName": "CreateUser", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "AWS Internal",
"requestParameters": {"userName": "backup-admin"},
"responseElements": {"user": {"userName": "backup-admin", "arn": "arn:aws:iam::111122223333:user/backup-admin"}},
"eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:41:39Z",
"userIdentity": {"type": "Root", "principalId": "111122223333", "arn": "arn:aws:iam::111122223333:root", "accountId": "111122223333"},
"eventSource": "iam.amazonaws.com", "eventName": "AttachUserPolicy", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "AWS Internal",
"requestParameters": {"userName": "backup-admin", "policyArn": "arn:aws:iam::aws:policy/AdministratorAccess"},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:42:02Z",
"userIdentity": {"type": "Root", "principalId": "111122223333", "arn": "arn:aws:iam::111122223333:root", "accountId": "111122223333"},
"eventSource": "iam.amazonaws.com", "eventName": "CreateAccessKey", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "AWS Internal",
"requestParameters": {"userName": "backup-admin"},
"responseElements": {"accessKey": {"userName": "backup-admin", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "status": "Active"}},
"eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T10:15:40Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLECIDEPLOY1", "arn": "arn:aws:iam::111122223333:user/ci-deploy", "accountId": "111122223333", "accessKeyId": "AKIAI44QH8DHBEXAMPLE", "userName": "ci-deploy"},
"eventSource": "s3.amazonaws.com", "eventName": "PutBucketPolicy", "awsRegion": "us-east-1",
"sourceIPAddress": "203.0.113.50", "userAgent": "terraform/1.13.4",
"requestParameters": {"bucketName": "acme-artifacts", "Host": "acme-artifacts.s3.us-east-1.amazonaws.com", "policy": "",
"bucketPolicy": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::111122223333:role/build"}, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::acme-artifacts/*"}]}},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:46:30Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEBACKUP0001", "arn": "arn:aws:iam::111122223333:user/backup-admin", "accountId": "111122223333", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "userName": "backup-admin"},
"eventSource": "s3.amazonaws.com", "eventName": "PutBucketPolicy", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "aws-cli/2.31.0",
"requestParameters": {"bucketName": "acme-reports", "Host": "acme-reports.s3.us-east-1.amazonaws.com", "policy": "",
"bucketPolicy": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::acme-reports/*"}]}},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:45:12Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEBACKUP0001", "arn": "arn:aws:iam::111122223333:user/backup-admin", "accountId": "111122223333", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "userName": "backup-admin"},
"eventSource": "s3.amazonaws.com", "eventName": "ListBuckets", "awsRegion": "us-east-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "aws-cli/2.31.0",
"requestParameters": {"Host": "s3.us-east-1.amazonaws.com"},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T09:05:00Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEALICE0001", "arn": "arn:aws:iam::111122223333:user/alice", "accountId": "111122223333", "userName": "alice"},
"eventSource": "ec2.amazonaws.com", "eventName": "DescribeInstances", "awsRegion": "eu-west-1",
"sourceIPAddress": "203.0.113.10", "userAgent": "Mozilla/5.0",
"requestParameters": {"instancesSet": {}, "filterSet": {}},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"}
]}
Real CloudTrail files arrive in S3 gzip-compressed, one per account, region and delivery interval.
gunzip them first, and the scripts below read them unchanged.
What a log file looks like
Start by listing the records exactly as they appear in the file:
# peek_trail.py: list the records in a CloudTrail log file, in file order
import json
import sys
with open(sys.argv[1]) as f:
records = json.load(f)["Records"]
print(len(records), "records")
for r in records:
who = r["userIdentity"]
print(r["eventTime"], f"{who['type']:8}", f"{who.get('userName', '-'):13}", r["eventName"])
$ python peek_trail.py cloudtrail-sample.json
10 records
2026-10-14T22:47:55Z IAMUser backup-admin PutBucketAcl
2026-10-14T09:02:13Z IAMUser alice ConsoleLogin
2026-10-14T22:38:51Z Root - ConsoleLogin
2026-10-14T22:41:07Z Root - CreateUser
2026-10-14T22:41:39Z Root - AttachUserPolicy
2026-10-14T22:42:02Z Root - CreateAccessKey
2026-10-14T10:15:40Z IAMUser ci-deploy PutBucketPolicy
2026-10-14T22:46:30Z IAMUser backup-admin PutBucketPolicy
2026-10-14T22:45:12Z IAMUser backup-admin ListBuckets
2026-10-14T09:05:00Z IAMUser alice DescribeInstances
Two things to notice before writing any rules. The records are out of order: AWS documents that a
log file isn’t an ordered trace of calls, and the first line here happened last. The root user has
no userName, so userIdentity.type == "Root" is how to find it. Searching ARNs for the text
root would also match a user named root-backup.
First pass, and the crash every CloudTrail parser hits
A first hunt for root logins, IAM changes and bucket permission changes. It prints the user or bucket each event touched:
# hunt.py: flag root logins, IAM changes and bucket access changes
import json
import sys
IAM_CHANGES = {"CreateUser", "CreateAccessKey", "AttachUserPolicy", "AttachRolePolicy",
"PutUserPolicy", "PutRolePolicy", "CreatePolicyVersion"}
with open(sys.argv[1]) as f:
records = json.load(f)["Records"]
for r in records:
params = r.get("requestParameters", {})
target = params.get("userName") or params.get("bucketName")
if r["eventName"] == "ConsoleLogin" and r["userIdentity"]["type"] == "Root":
print(r["eventTime"], "ROOT LOGIN from", r["sourceIPAddress"])
elif r["eventName"] in IAM_CHANGES:
print(r["eventTime"], "IAM", r["eventName"], target)
elif r["eventName"] in {"PutBucketPolicy", "PutBucketAcl"}:
print(r["eventTime"], "BUCKET", r["eventName"], target)
$ python hunt.py cloudtrail-sample.json
2026-10-14T22:47:55Z BUCKET PutBucketAcl acme-reports
Traceback (most recent call last):
File "/lab/hunt.py", line 13, in <module>
target = params.get("userName") or params.get("bucketName")
^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'get'
The second record is a ConsoleLogin, and its requestParameters is null. The key is present,
so .get("requestParameters", {}) returns None, not the default. The default only applies when
a key is missing. responseElements is null on many calls too. Use or {}, which covers both a
missing key and a null value:
# hunt.py: flag root logins, IAM changes and bucket access changes
import json
import sys
IAM_CHANGES = {"CreateUser", "CreateAccessKey", "AttachUserPolicy", "AttachRolePolicy",
"PutUserPolicy", "PutRolePolicy", "CreatePolicyVersion"}
with open(sys.argv[1]) as f:
records = json.load(f)["Records"]
for r in records:
params = r.get("requestParameters") or {} # the key can be present with null
target = params.get("userName") or params.get("bucketName")
if r["eventName"] == "ConsoleLogin" and r["userIdentity"]["type"] == "Root":
print(r["eventTime"], "ROOT LOGIN from", r["sourceIPAddress"])
elif r["eventName"] in IAM_CHANGES:
print(r["eventTime"], "IAM", r["eventName"], target)
elif r["eventName"] in {"PutBucketPolicy", "PutBucketAcl"}:
print(r["eventTime"], "BUCKET", r["eventName"], target)
$ python hunt.py cloudtrail-sample.json
2026-10-14T22:47:55Z BUCKET PutBucketAcl acme-reports
2026-10-14T22:38:51Z ROOT LOGIN from 198.51.100.23
2026-10-14T22:41:07Z IAM CreateUser backup-admin
2026-10-14T22:41:39Z IAM AttachUserPolicy backup-admin
2026-10-14T22:42:02Z IAM CreateAccessKey backup-admin
2026-10-14T10:15:40Z BUCKET PutBucketPolicy acme-artifacts
2026-10-14T22:46:30Z BUCKET PutBucketPolicy acme-reports
It runs, and two problems are left. The output still follows file order, so the bucket change
prints before the login that led to it. And ci-deploy’s Terraform change to acme-artifacts is
flagged next to the attacker’s, though it only grants one role in the same account. A bucket
policy change isn’t an incident in itself. A change that grants access to everyone is.
Rules that look inside the request
The rules move into a module. A bucket policy is public when an Allow statement has the principal
*, and an ACL is public when it uses a canned public ACL. That is a deliberate simplification:
AWS also counts {"AWS": ["*"]} and the authenticated-read ACL as public, and does not count a
* principal narrowed by a condition such as aws:SourceIp or aws:PrincipalOrgID. IAM Access
Analyzer applies the full definition.
# rules.py: which CloudTrail events matter, and why
IAM_CHANGES = {"CreateUser", "CreateAccessKey", "AttachUserPolicy", "AttachRolePolicy",
"PutUserPolicy", "PutRolePolicy", "CreatePolicyVersion"}
PUBLIC_ACLS = {"public-read", "public-read-write"}
def public_policy(policy):
statements = policy.get("Statement", [])
if isinstance(statements, dict): # a single statement may be an object
statements = [statements]
return any(s.get("Effect") == "Allow" and s.get("Principal") in ("*", {"AWS": "*"})
for s in statements)
def finding(r):
"""A short reason this event matters, or None."""
name, params = r["eventName"], r.get("requestParameters") or {}
if name == "ConsoleLogin" and r["userIdentity"]["type"] == "Root":
return "root console login, MFA " + r["additionalEventData"]["MFAUsed"]
if name in IAM_CHANGES:
return f"{name} {params.get('userName') or params.get('roleName')}"
if name == "PutBucketPolicy" and public_policy(params.get("bucketPolicy") or {}):
return f"public bucket policy on {params['bucketName']}"
if name == "PutBucketAcl" and PUBLIC_ACLS & set(params.get("x-amz-acl", [])):
return f"public ACL on {params['bucketName']}"
return None
The timeline loads every file it is given, sorts by eventTime, and prints only the events a rule
flags:
# timeline.py: flagged events from one or more CloudTrail files, oldest first
import json
import sys
from rules import finding
records = []
for path in sys.argv[1:]:
with open(path) as f:
records.extend(json.load(f)["Records"])
for r in sorted(records, key=lambda r: r["eventTime"]):
reason = finding(r)
if reason:
who = r["userIdentity"]
actor = who.get("userName", who["type"])
print(r["eventTime"], f"{actor:12}", f"{r['sourceIPAddress']:14}", reason)
$ python timeline.py cloudtrail-sample.json
2026-10-14T22:38:51Z Root 198.51.100.23 root console login, MFA No
2026-10-14T22:41:07Z Root 198.51.100.23 CreateUser backup-admin
2026-10-14T22:41:39Z Root 198.51.100.23 AttachUserPolicy backup-admin
2026-10-14T22:42:02Z Root 198.51.100.23 CreateAccessKey backup-admin
2026-10-14T22:46:30Z backup-admin 198.51.100.23 public bucket policy on acme-reports
2026-10-14T22:47:55Z backup-admin 198.51.100.23 public ACL on acme-reports
Sorted, it reads as an attack. Root signed in without MFA, created backup-admin, attached
admin rights to it and gave it an access key. Then backup-admin opened acme-reports to the
internet twice over, through both a policy and an ACL. Persistence came first, so revoking the root
password alone would not have ended it. ci-deploy’s change is gone because it isn’t public.
Sorting on the eventTime string is safe because CloudTrail always writes UTC in one fixed format.
If you merge in logs that carry other offsets, parse them to timezone-aware datetimes first.
Practice
- A second log file from
eu-west-1arrived. Save it ascloudtrail-sample-2.json(a sample, like the first). Extendtimeline.pyto flag CloudTrail tampering (StopLogging,DeleteTrail,UpdateTrail,PutEventSelectors) and run it over both files.
{"Records": [
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:51:18Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEBACKUP0001", "arn": "arn:aws:iam::111122223333:user/backup-admin", "accountId": "111122223333", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "userName": "backup-admin"},
"eventSource": "ec2.amazonaws.com", "eventName": "RunInstances", "awsRegion": "eu-west-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "aws-cli/2.31.0",
"requestParameters": {"instanceType": "p5.48xlarge", "instancesSet": {"items": [{"imageId": "ami-0abcdef1234567890", "minCount": 4, "maxCount": 4}]}},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"},
{"eventVersion": "1.11", "eventTime": "2026-10-14T22:49:30Z",
"userIdentity": {"type": "IAMUser", "principalId": "AIDAEXAMPLEBACKUP0001", "arn": "arn:aws:iam::111122223333:user/backup-admin", "accountId": "111122223333", "accessKeyId": "AKIAIOSFODNN7EXAMPLE", "userName": "backup-admin"},
"eventSource": "cloudtrail.amazonaws.com", "eventName": "StopLogging", "awsRegion": "eu-west-1",
"sourceIPAddress": "198.51.100.23", "userAgent": "aws-cli/2.31.0",
"requestParameters": {"name": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-audit"},
"responseElements": null, "eventType": "AwsApiCall", "recipientAccountId": "111122223333"}
]}
Solution
# timeline.py: flagged events, now including CloudTrail tampering, oldest first
import json
import sys
from rules import finding
TAMPERING = {"StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors"}
records = []
for path in sys.argv[1:]:
with open(path) as f:
records.extend(json.load(f)["Records"])
for r in sorted(records, key=lambda r: r["eventTime"]):
reason = finding(r)
if r["eventName"] in TAMPERING:
reason = f"{r['eventName']} {r['requestParameters']['name']}"
if reason:
who = r["userIdentity"]
actor = who.get("userName", who["type"])
print(r["eventTime"], f"{actor:12}", f"{r['sourceIPAddress']:14}", reason)
$ python timeline.py cloudtrail-sample.json cloudtrail-sample-2.json
2026-10-14T22:38:51Z Root 198.51.100.23 root console login, MFA No
2026-10-14T22:41:07Z Root 198.51.100.23 CreateUser backup-admin
2026-10-14T22:41:39Z Root 198.51.100.23 AttachUserPolicy backup-admin
2026-10-14T22:42:02Z Root 198.51.100.23 CreateAccessKey backup-admin
2026-10-14T22:46:30Z backup-admin 198.51.100.23 public bucket policy on acme-reports
2026-10-14T22:47:55Z backup-admin 198.51.100.23 public ACL on acme-reports
2026-10-14T22:49:30Z backup-admin 198.51.100.23 StopLogging arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-audit
One caveat before reusing this: StopLogging, DeleteTrail and UpdateTrail log the trail as
name, but PutEventSelectors logs it as trailName. The sample has no PutEventSelectors record,
so the output above is unaffected; on real logs, use params.get("name") or params.get("trailName").
The events from the second file slot into place by time, because the sort runs after every file is
loaded. StopLogging is the attacker trying to make the next steps invisible. Events after it
are missing from that trail, which is why trails should deliver to a separate, locked-down log
account and why StopLogging deserves an alarm of its own.
- Pivot on the attacker’s IP. List every event from
198.51.100.23across both files, flagged or not. What did the rules miss?
Solution
# pivot_ip.py: every event from one source IP, flagged or not, oldest first
import json
import sys
ip, paths = sys.argv[1], sys.argv[2:]
records = []
for path in paths:
with open(path) as f:
records.extend(json.load(f)["Records"])
for r in sorted(records, key=lambda r: r["eventTime"]):
if r["sourceIPAddress"] == ip:
print(r["eventTime"], r["awsRegion"], r["eventName"])
$ python pivot_ip.py 198.51.100.23 cloudtrail-sample.json cloudtrail-sample-2.json
2026-10-14T22:38:51Z us-east-1 ConsoleLogin
2026-10-14T22:41:07Z us-east-1 CreateUser
2026-10-14T22:41:39Z us-east-1 AttachUserPolicy
2026-10-14T22:42:02Z us-east-1 CreateAccessKey
2026-10-14T22:45:12Z us-east-1 ListBuckets
2026-10-14T22:46:30Z us-east-1 PutBucketPolicy
2026-10-14T22:47:55Z us-east-1 PutBucketAcl
2026-10-14T22:49:30Z eu-west-1 StopLogging
2026-10-14T22:51:18Z eu-west-1 RunInstances
Two events no rule flagged: ListBuckets, reconnaissance before choosing a bucket, and
RunInstances in a second region, launching expensive GPU instances, which is typical of
cryptomining. Rules find the starting point, and pivoting on what they reveal (an IP, a user, a
key) finds the rest of the incident.
- Follow the access key
AKIAIOSFODNN7EXAMPLE: find the event that created it and every call signed with it. Where does the key’s ID appear in each kind of record?
Solution
# pivot_key.py: where an access key was created, and everything signed with it
import json
import sys
key, paths = sys.argv[1], sys.argv[2:]
records = []
for path in paths:
with open(path) as f:
records.extend(json.load(f)["Records"])
for r in sorted(records, key=lambda r: r["eventTime"]):
created = (r.get("responseElements") or {}).get("accessKey", {}).get("accessKeyId")
if created == key:
print(r["eventTime"], "CREATED by", r["userIdentity"]["type"], "for",
r["requestParameters"]["userName"])
elif r["userIdentity"].get("accessKeyId") == key:
print(r["eventTime"], "used for", r["eventName"])
$ python pivot_key.py AKIAIOSFODNN7EXAMPLE cloudtrail-sample.json cloudtrail-sample-2.json
2026-10-14T22:42:02Z CREATED by Root for backup-admin
2026-10-14T22:45:12Z used for ListBuckets
2026-10-14T22:46:30Z used for PutBucketPolicy
2026-10-14T22:47:55Z used for PutBucketAcl
2026-10-14T22:49:30Z used for StopLogging
2026-10-14T22:51:18Z used for RunInstances
On the creating call, the new key is in responseElements.accessKey.accessKeyId. On every call
made with it, it is in userIdentity.accessKeyId. Linking the two proves that every action by
backup-admin traces back to the root session, which is the case for deactivating that key, and
for rotating the root credentials, before anything else.