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.
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.
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.
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.
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.
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.
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.
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.
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.
First question: what is in there at all?
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;
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.
This is the query I would put in front of a customer first. Not a detection — a description of normal.
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.
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.
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;
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.
AccessDenied. Nineteen buckets, and nineteen identical answers per question.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.
The friction worth warning you about. I quit the session, came back, and got this:
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:
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:
| Thing | Survives a restart? |
|---|---|
| Macros, tables, views | Yes, in the database file |
| Secrets | No, unless PERSISTENT |
| Variables | No, session only |
| Extensions | On 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.
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:
| File | Would read |
|---|---|
forensics.db | CloudTrail — built, and what this post covers |
vpc.db | VPC Flow Logs — not built yet |
cur.db | Cost and Usage Report — not built yet |
config.db | Config 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.
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.
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.
INSTALL httpfs; INSTALL aws; INSTALL json; while things are calm./opt/duckdb/queries, so an incident starts with duckdb -init rather than with writing SQL from memory..../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.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.
unnest(Records) plus union_by_name=true turns gzipped nested JSON into a flat table, and you never write json_extract_scalar again.WHERE NOT read_only is the filter that makes everything else legible.error_code IS NOT NULL catches “this bucket has no CORS config” with the same enthusiasm as AccessDenied. Match the authorization errors by name.identity_type as well, or those 21 calls vanish from your results without a warning.COPY TO writes the one result worth keeping, and the throwaway queries leave nothing behind — which I have come to prefer.