CloudTrailDuckDBIncident ResponseDetection

Reading CloudTrail with DuckDB

AWS bought the team behind DuckDB in August, so I put it on an EC2 instance and read CloudTrail straight out of S3 — no Glue crawler, no Athena workgroup. A throwaway account with 118 events in it was enough to prove the detection query I had already written was wrong — and to convince me this is worth building out properly.

VR
Vishnu Rachapudi Cloud & AI Engineer · AWS Community Builder (Security)
September 2026
12 min read

In this post

  1. 01The query I was going to ship
  2. 02Why CloudTrail is the awkward shape
  3. 03Getting DuckDB onto the box
  4. 04One macro, and the nesting goes away
  5. 05What 118 events actually look like
  6. 06The baseline table
  7. 07The burst that looked like an attack
  8. 08Four kinds of state, four lifetimes
  9. 09Where I think this goes
  10. 10How I would actually run this

01The query I was going to ship

AWS signed the agreement to acquire DuckLabs, the team behind DuckDB, on 26 August 2026. The database stays MIT-licensed under the independent DuckDB Foundation and the founders keep leading the technical direction, so nothing changes for anyone using it. But the collaboration that led there started in early 2025 around S3 Tables and SageMaker Lakehouse, which tells you where this is going: S3 as a query target rather than a storage tier.

That was enough to make me try something I had been putting off. CloudTrail forensics without Athena.

I had already written the queries. I had tested them against synthetic CloudTrail I generated myself, with a fake compromised key, a fake enumeration burst, a fake pile of AccessDenied errors. Everything worked. Then I pointed the same queries at a real account with almost nothing in it, and one of them fell over in a way I would not have caught until it paged someone.

That is most of what this post is about.

02Why CloudTrail is the awkward shape

A CloudTrail file in S3 is gzipped JSON, and every event you want is buried inside a top-level Records array:

{"Records": [
  {"eventTime": "...", "eventName": "RunInstances",
   "userIdentity": {"type": "Root", "arn": "..."},
   "requestParameters": {...}},
  ...
]}

Nested objects, inside an array, inside a gzipped file, spread across thousands of small objects. That shape is why Athena over raw CloudTrail is slow, and why every example query you find online is a thicket of json_extract_scalar calls.

03Getting DuckDB onto the box

There is no server and no cluster. DuckDB is a single binary that runs inside whatever process you start, so a t3.large with an instance profile is the whole environment.

Installing the DuckDB CLI on Amazon Linux 2023: architecture detected as x86_64, release tag resolved from the GitHub API, binary installed to /usr/local/bin, version reported as v1.5.5.
Architecture detected, release resolved from the GitHub releases API, binary installed. Nothing else to stand up.

Then load the S3 extension and create a secret. PROVIDER credential_chain resolves credentials the way the AWS SDK does, so on EC2 it reads the instance profile from IMDS and no key material ever appears in SQL or on disk.

A DuckDB session creating an S3 secret with TYPE s3, PROVIDER credential_chain and REGION us-east-1, returning Success true.
The instance profile does the work. Set REGION explicitly — section 08 explains what happens when you do not.

Before writing any SQL, confirm access. This is the step that fails, and it fails for ordinary IAM reasons rather than interesting ones.

A DuckDB glob query over the CloudTrail prefix in S3 returning a count of five gzipped objects.
Five files. If this returns a number, credentials and bucket access are both fine and everything after it is just SQL.

Worth remembering

Run this in the same region as the trail bucket. My trail was in us-east-1, and pointing an instance in Mumbai at it means data transfer charges plus latency on every scan — which is miserable when the whole point is a fast iteration loop.

04One macro, and the nesting goes away

unnest(Records) explodes the array into one row per event. format='auto' handles the gzip. Struct fields come out with dot notation, so r.userIdentity.arn is just a column reference and there are no JSON functions anywhere.

Creating a DuckDB table macro named trail that unnests the CloudTrail Records array and projects event_time, event_name, source_ip, principal_arn and other fields, then a count returning 118 events.
Five files, 118 events. Because it is a table macro the path is a parameter, so the same definition works against a local copy or any S3 prefix.

Worth remembering

union_by_name=true is not optional. CloudTrail's schema varies between files — a day with both successful and failed calls has errorCode in some objects and not others. Without it the scan fails on a schema mismatch, and the error points at a file rather than at the real cause.

05What 118 events actually look like

First question: what is in there at all?

The top twenty CloudTrail API calls by count. Every entry is a Get, List or Describe operation, led by GetBucketAcl at 19 calls.
Every entry in the top twenty is a Get, List or Describe. GetBucketAcl at 19 is CloudTrail checking it can still write to its own bucket.

Not one mutation in the whole list. Most of any trail is the platform talking to itself — the SSM agent reporting in, the console loading panels, services polling their own configuration. Which is why the first filter earns its place immediately:

SELECT event_time, event_name, identity_type, source_ip
FROM trail(getvariable('trail_path'))
WHERE NOT read_only
ORDER BY event_time;
Six write events ordered by time: RunInstances and CreateSecurityGroup as Root, CreateChat as AssumedRole, StartSession as Root, then CreateDataChannel and UpdateInstanceInformation as AssumedRole. Source IP column redacted.
Six rows out of 118, and they read as a story: me launching the instance, opening a Session Manager session, then the instance reporting in through its own role.

One human, two credential paths. My console actions arrive as Root from a home IP; the instance's own calls arrive as an AssumedRole from its public address. Worth knowing before you build anything that treats a new IP as suspicious.

readOnly is CloudTrail's own flag, incidentally, not something you infer from the verb name. One boolean instead of a regex over API names.

06The baseline table

This is the query I would put in front of a customer first. Not a detection — a description of normal.

Nine rows grouping CloudTrail events by principal ARN, source IP and user agent. Root on Chrome has 83 events, cloudtrail.amazonaws.com with a NULL principal has 19, and the instance role appears twice under two different user agents. Source IP column redacted.
Nine rows for an entire day. Note the NULL principals, and the same role appearing under two different user agents.

Two things fall out of it.

A NULL principal is normal. AWS services calling on their own behalf have no ARN at all: identity_type is AWSService and principal_arn is NULL. That was 21 calls here. Any detection keyed on principal_arn drops them silently, which is the worst way for a rule to fail.

The user agent discriminates better than the IP. The instance role shows up as both amazon-ssm-agent/3.3.4624.0 and a bare Go-http-client/1.1. A bare HTTP client is also what attacker tooling looks like, so allowlisting user agents is fragile in both directions.

CloudTrail events grouped by identity type. Root accounts for 93 calls across 36 distinct APIs in a five minute window; AWSService for 21 calls with a NULL ARN; three assumed roles for the remainder.
93 of 118 calls were root, across 36 distinct APIs, inside a five minute window.

I know better than that. It is a throwaway account, which is precisely the excuse everyone uses, and it is the first thing I would flag in someone else's environment. One query on my own data made a point that a hundred best-practice posts had not.

07The burst that looked like an attack

Nothing had failed yet, so I generated some errors and ran the query I had already written for this post:

SELECT date_trunc('minute', event_time) AS minute, principal_arn, source_ip,
       count(*) AS denied, count(DISTINCT event_name) AS apis
FROM trail(getvariable('trail_path'))
WHERE error_code IS NOT NULL
GROUP BY 1,2,3 ORDER BY denied DESC;
Three rows of failed API calls grouped by minute. The top row shows 201 failures across 13 distinct APIs in one minute from the AIOpsRole-DefaultInvestigationGroup AIOpsAssistant role, calling from aiops.amazonaws.com.
201 failures in one minute across 13 distinct APIs. High volume, many APIs, one narrow window, everything failing.

That is textbook enumeration. If I had seen that shape in a customer account I would have escalated it. So before doing anything else, I looked at what it was actually failing on.

Error code breakdown for the AIOpsAssistant role: NoSuchCORSConfiguration, ReplicationConfigurationNotFoundError, MetadataConfigurationNotFound, NoSuchLifecycleConfiguration, ObjectLockConfigurationNotFoundError and similar, each occurring 16 to 19 times. No AccessDenied anywhere.
Not a single AccessDenied. Nineteen buckets, and nineteen identical answers per question.

What I got wrong

Every one of those is a bucket politely saying “I do not have that configured.” CloudWatch AIOps was walking my buckets asking about CORS, replication, lifecycle and object lock, and getting a negative answer each time. Completely normal discovery behaviour with nothing broken behind it.

My query had conflated failed with denied. error_code IS NOT NULL catches NoSuchCORSConfiguration exactly as eagerly as it catches AccessDenied, and in a real account those benign not-found responses outnumber the interesting ones by orders of magnitude. Shipped as a detection rule, that query pages someone at 3 a.m. because an AWS service asked a bucket a question and was told no.

The fix is to match authorization failures specifically, and to drop service principals calling from AWS endpoints:

SELECT date_trunc('minute', event_time) AS minute,
       principal_arn, source_ip,
       count(*)                              AS denials,
       count(DISTINCT event_name)            AS apis,
       string_agg(DISTINCT error_code, ', ') AS codes
FROM trail(getvariable('trail_path'))
WHERE regexp_matches(error_code,
        '(AccessDenied|UnauthorizedOperation|Forbidden|InvalidClientTokenId|SignatureDoesNotMatch)')
  AND source_ip NOT LIKE '%.amazonaws.com'
  AND identity_type <> 'AWSService'
GROUP BY 1, 2, 3
HAVING denials > 5
ORDER BY denials DESC;

That also excludes NoSuchEntity, which is a missing resource rather than a refused permission — the error you get probing for roles that do not exist, and one you would otherwise treat as a signal.

The uncomfortable part is that the original query passed every synthetic test I gave it, because every error I planted was an AccessDenied. I had built a test set that only contained the thing I was looking for. An almost empty real account falsified the query in under an hour.

08Four kinds of state, four lifetimes

The friction worth warning you about. I quit the session, came back, and got this:

A DuckDB error: HTTP GET error reading the S3 path, HTTP 403 Forbidden, AccessDenied, and a message reading Authentication Failure - this is usually caused by invalid or missing credentials. Above it a warning notes the S3 glob ran from incorrect region empty string.
“Invalid or missing credentials” sends you straight to IAM. The instance profile was fine the whole time — the real clue is from incorrect region "" in the warning above it.

An empty region means no secret existed at all, so DuckDB fell back to unsigned requests and S3 refused them. Relaunching also lost the variable, with an equally indirect message:

A DuckDB parser error reading: read_json cannot take NULL list as parameter, pointing at the trail macro call.
That is a NULL variable, not a JSON problem. getvariable('trail_path') returned nothing, so read_json received a NULL path.

Four things that all feel like setup, and every one of them persists differently:

ThingSurvives a restart?
Macros, tables, viewsYes, in the database file
SecretsNo, unless PERSISTENT
VariablesNo, session only
ExtensionsOn disk, but need LOAD

An init file settles all four at once:

# /root/.duckdbrc — loaded automatically on every launch
LOAD httpfs;
LOAD aws;
CREATE OR REPLACE SECRET ct (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1');
SET VARIABLE trail_path = 's3://aws-cloudtrail-logs-.../2026/09/11/*.json.gz';

Set REGION explicitly even though credential_chain can usually infer it. With no secret loaded there is nothing to infer from, and you pay an extra round trip on every glob while DuckDB works it out.

09Where I think this goes

Everything above is one log source. I built forensics.db for CloudTrail and that is all I have actually run — so the rest of this section is a projection, not a report.

But CloudTrail was the awkward case. Gzipped JSON with events nested inside an array is the hardest shape AWS delivers logs in, and if one macro handles that, the others should be easier rather than harder. Flow Logs can be delivered as Hive-partitioned Parquet, which needs no parsing at all. The Cost and Usage Report is already Parquet. Config snapshots are JSON inventory.

So the long-term idea I am working towards is one database file per log source, each carrying its own macro and its own sheet of staged queries:

FileWould read
forensics.dbCloudTrail — built, and what this post covers
vpc.dbVPC Flow Logs — not built yet
cur.dbCost and Usage Report — not built yet
config.dbConfig snapshots — not built yet

The reason I think it holds up is that the database file stays tiny. It holds macros and saved baselines, not log data — a few kilobytes, small enough to commit to a repo and carry between accounts. If that works, the next engagement starts with a query sheet that already knows the shape of the data instead of a crawler you are waiting on. I will find out whether it works when I build the second one.

The output question

One difference from Athena worth being straight about, because it caught me out. Athena writes every query result to an S3 results bucket automatically. DuckDB writes nothing unless you ask, so by default the work disappears when the session ends.

Asking is one statement:

COPY (
  SELECT principal_arn, source_ip, user_agent, count(*) AS events
  FROM trail(getvariable('trail_path'))
  GROUP BY 1,2,3
) TO 's3://findings/baselines/acct-1234/2026-09-11.parquet' (FORMAT parquet);

I have come round to preferring it this way. Athena stores everything whether it was worth keeping or not, and you pay to retain the noise. Here the thirty throwaway variations leave nothing behind, and the one query that produced something useful gets written down deliberately. Run that monthly and the baseline becomes a series you can diff, which is worth more than any single run of it.

Worth remembering

The asset is the baseline, not the engine. A written-down description of normal for an account — which principals act, from which addresses, under which user agents, what the read-only chatter looks like — is what makes detection possible at all. You cannot recognise new without it. Whatever queries that baseline is an implementation detail, and it will change.

10How I would actually run this

  1. Put the install in an SSM document, not in your shell history. Idempotent, architecture-aware for Graviton, attached as a State Manager Association so the tooling is already on the instance before anything goes wrong.
  2. Pre-fetch the extensions during install. DuckDB downloads them from the internet on first use, and an instance in a private subnet during an incident may have no egress — which you would discover at the worst possible moment. INSTALL httpfs; INSTALL aws; INSTALL json; while things are calm.
  3. Stage the query library on disk alongside it, at something like /opt/duckdb/queries, so an incident starts with duckdb -init rather than with writing SQL from memory.
  4. Narrow the prefix before you widen it. .../2026/09/11/*.json.gz scans a day; .../2026/09/** scans a month. Same pruning logic Athena bills you for, except here it only costs wall-clock time.
  5. Build the baseline table first and keep it. A detection is just a new row appearing in that table, and you cannot recognise new without having written down normal.
  6. Test every rule against a real account before you trust it, even a boring one. Especially a boring one — the benign noise is the part synthetic data never gets right.

I want to be careful about what I am claiming here, because there is a version of this post that says Athena is finished and it would be wrong.

The moment you need continuous detection across an organisation, retention queries spanning years, or a shared query surface that a SOC team hits concurrently, the catalog stops being overhead and becomes the entire point. That is Security Lake, or Athena, or your SIEM. Those are platforms, and platforms need the infrastructure I spent this post avoiding.

What DuckDB replaced is the exploratory loop. Athena's $5/TB is not expensive in absolute terms; it is expensive relative to zero when one person runs thirty variations of the same query in an hour, which is exactly what incident scoping looks like. And the setup tax is paid per account, every time, which for anyone working across a lot of customer environments is the cost that actually accumulates.

So the honest positioning is narrower than “replacement” and more useful than “neat trick”: this is ad-hoc analytical SQL over log data with no infrastructure to stand up or maintain, and at scale it gives you a faster picture than waiting on a crawler to finish.

Key takeaways

  • One macro removes the whole CloudTrail problem. unnest(Records) plus union_by_name=true turns gzipped nested JSON into a flat table, and you never write json_extract_scalar again.
  • Most of a trail is the platform. 112 of my 118 events were read-only service and console chatter. WHERE NOT read_only is the filter that makes everything else legible.
  • Failed is not denied. error_code IS NOT NULL catches “this bucket has no CORS config” with the same enthusiasm as AccessDenied. Match the authorization errors by name.
  • NULL principals are real events. AWS services have no ARN. Group on identity_type as well, or those 21 calls vanish from your results without a warning.
  • Synthetic data validates your assumptions, not your query. Mine passed every planted test and failed on a near-empty real account within the hour.
  • Nothing is stored unless you ask. No automatic results bucket like Athena's. COPY TO writes the one result worth keeping, and the throwaway queries leave nothing behind — which I have come to prefer.
VR
Vishnu Rachapudi More posts at vishnurachapudi.com · github.com/aquavis12