#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = ["boto3>=1.34"] # /// """Look up CloudTrail write events (who created/changed what) as compact JSON. By default returns management write events (ReadOnly=false) in the window. Pass --event-name to look up a specific API call instead (e.g. RunInstances). Usage: uv run scripts/cloudtrail_events.py --start 2026-06-01 --end 2026-06-03 uv run scripts/cloudtrail_events.py --start ... --end ... --event-name RunInstances uv run scripts/cloudtrail_events.py --start ... --end ... --region eu-west-1 """ import argparse import json from datetime import datetime, timedelta, timezone import boto3 PARAMS_MAX_CHARS = 300 def parse_date(s: str) -> datetime: return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--start", required=True, type=parse_date, help="window start (YYYY-MM-DD, UTC)") parser.add_argument("--end", required=True, type=parse_date, help="window end (YYYY-MM-DD, UTC, inclusive)") parser.add_argument("--event-name", help="look up one specific event name instead of all write events") parser.add_argument("--max", type=int, default=50, help="max detailed events returned (default: 50)") parser.add_argument("--profile", help="AWS profile name") parser.add_argument("--region", help="region to query (default: session/default region)") args = parser.parse_args() session = boto3.Session(profile_name=args.profile, region_name=args.region) ct = session.client("cloudtrail") if args.event_name: attrs = [{"AttributeKey": "EventName", "AttributeValue": args.event_name}] else: attrs = [{"AttributeKey": "ReadOnly", "AttributeValue": "false"}] end = args.end + timedelta(days=1) # make --end inclusive events = [] counts = {} token = None while True: kwargs = { "LookupAttributes": attrs, "StartTime": args.start, "EndTime": end, "MaxResults": 50, } if token: kwargs["NextToken"] = token resp = ct.lookup_events(**kwargs) for e in resp.get("Events", []): detail = json.loads(e.get("CloudTrailEvent", "{}")) name = e.get("EventName") counts[name] = counts.get(name, 0) + 1 if len(events) < args.max: params = json.dumps(detail.get("requestParameters") or {}, default=str) if len(params) > PARAMS_MAX_CHARS: params = params[:PARAMS_MAX_CHARS] + "...(truncated)" events.append( { "time": e.get("EventTime").isoformat() if e.get("EventTime") else None, "event_name": name, "event_source": e.get("EventSource"), "username": e.get("Username"), "aws_region": detail.get("awsRegion"), "source_ip": detail.get("sourceIPAddress"), "error_code": detail.get("errorCode"), "resources": [ {"type": r.get("ResourceType"), "name": r.get("ResourceName")} for r in (e.get("Resources") or [])[:5] ], "request_parameters": params, } ) token = resp.get("NextToken") # stop paginating once we have enough detail; counts past this point # would cost more requests than they're worth if not token or len(events) >= args.max: break events.sort(key=lambda x: x["time"] or "", reverse=True) print(json.dumps( { "window": {"start": args.start.isoformat(), "end": end.isoformat()}, "region_queried": ct.meta.region_name, "filter": args.event_name or "ReadOnly=false (all write events)", "event_name_counts": dict(sorted(counts.items(), key=lambda kv: -kv[1])), "events_returned": len(events), "events": events, }, indent=1, )) if __name__ == "__main__": main()