Skip to main content
Cloud Security · lesson 8 of 8
Cloud Security intermediate Lesson 8 of 8

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

cloud-security aws cloudtrail threat-hunting incident-response python

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

  1. A second log file from eu-west-1 arrived. Save it as cloudtrail-sample-2.json (a sample, like the first). Extend timeline.py to 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.

  1. Pivot on the attacker’s IP. List every event from 198.51.100.23 across 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.

  1. 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.

Frequently Asked Questions

What is in an AWS CloudTrail log file?
A CloudTrail log file is a JSON object with a Records array. Each record is one API call or console event, with eventTime in UTC, eventSource such as iam.amazonaws.com, eventName such as CreateAccessKey, the caller in userIdentity, sourceIPAddress, and the call's requestParameters and responseElements. CloudTrail delivers the files to S3 gzip-compressed, per account and region, every few minutes.
How do I find root user activity in CloudTrail?
Match on userIdentity.type equal to Root. Root records usually have no userName, so filtering on a user name misses them, and searching ARNs for the text root also matches any user or role with root in its name. A root ConsoleLogin event also records whether MFA was used, in additionalEventData.MFAUsed.
Why does my CloudTrail parser crash with 'NoneType' object has no attribute 'get'?
Some events, such as ConsoleLogin, have requestParameters set to null rather than leaving the key out. record.get('requestParameters', {}) returns None in that case, because the key exists, so the next .get fails. Use record.get('requestParameters') or {} instead. responseElements is null for many calls too.
Are CloudTrail records in chronological order?
No. AWS documents that log files are not an ordered stack trace of API calls, and events can appear in any order within a file. Events are also spread over many files, per region and per delivery interval. Load every relevant file, then sort by eventTime, which is always UTC in the same format, before reading a sequence of events.
Which CloudTrail events suggest a compromised AWS account?
Common first signals are a root console login, especially without MFA, CreateUser or CreateAccessKey from an unfamiliar IP, admin policies attached with AttachUserPolicy or PutUserPolicy, S3 PutBucketPolicy or PutBucketAcl calls that grant access to everyone, StopLogging or DeleteTrail, and RunInstances for large instance types in unused regions.