SQL for security analysts: log analysis from zero to detection
If you can read a CloudTrail log, you already understand the data model. SQL just gives you a faster, repeatable way to ask questions of it — and a language for turning those questions into detection rules.
This lab starts from scratch. No SQL background assumed. By the end you'll have five working detection queries covering brute force, privilege escalation, post-compromise enumeration, after-hours access, and impossible travel — all running against a simulated CloudTrail dataset in a local SQLite database.
What you'll learn
- Set up a SQLite database from a realistic simulated CloudTrail CSV
- Write
SELECT,WHERE,GROUP BY,ORDER BY, and subquery statements - Frame every SQL concept as a concrete log analysis task
- Build 5 detection queries credible in a SOC or detection engineering context
- Understand why structured querying scales where manual log review doesn't
Prerequisites
- Ubuntu Linux (or WSL on Windows)
- Python 3 installed (
python3 --versionto check) - SQLite3 (
sudo apt install sqlite3) - No SQL experience required
Background: why SQL for log analysis
Manual log review works up to a point. grep, jq, and eyeballing JSON gets you through an incident when you know roughly what you're looking for. It breaks down when:
- You need to count occurrences across thousands of events
- You want to correlate two patterns — failed logins followed by IAM changes
- You want a rule you can re-run tomorrow with one command
SQL is a query language designed for exactly this. It runs against structured data — tables with rows and columns — and lets you filter, group, count, and join that data in ways that map directly to detection logic.
CloudTrail is already structured. Every API call produces a record with a known set of fields: who called it, when, from where, what resource, whether it succeeded. A table of those records is a natural fit for SQL.
SQLite
SQLite is a file-based database — no server, no configuration, no credentials. You point it at a .db file and start querying. For local labs and offline analysis it's the right tool.
The dataset
The simulated dataset covers 28 days of AWS API activity across 5 user accounts and ~393 events. It's generated with a Python script so you can reproduce or modify it. The dataset includes seeded anomalies that your detection queries will uncover:
- Brute force: 18 rapid
ConsoleLoginfailures from an external IP, followed by a successful login - Privilege escalation: IAM policy attachment granting
AdministratorAccessto a non-admin user - Post-compromise enumeration: 10 distinct read-only enumeration calls across IAM, EC2, S3, and RDS within 2 minutes
- After-hours access: A regular user accessing payroll data at 3am
- Impossible travel: The same user account authenticating from London and Tokyo 4 minutes apart
The dataset fields are:
| Field | Description |
|---|---|
event_time |
ISO 8601 timestamp of the API call |
event_name |
AWS API action (e.g. ConsoleLogin, AttachUserPolicy) |
source_ip |
IP address of the caller |
user_identity |
Full IAM ARN of the caller |
username |
Short username extracted from the ARN |
aws_region |
AWS region where the call was made |
error_code |
Empty string for success; error string for failures |
user_agent |
Client that made the call (console, CLI, SDK) |
resource_arn |
ARN of the resource targeted by the call |
Setup
1. Install SQLite
sudo apt install sqlite3
2. Create a working directory
mkdir ~/sql-lab && cd ~/sql-lab
3. Download the lab files
Download these three files into ~/sql-lab/:
generate_dataset.py— generatescloudtrail_sim.csvwith seeded anomalies (https://github.com/sknrsmkwcz/sql-lab-files/blob/main/generate_dataset.py)cloudtrail_sim.csv— the pre-generated dataset (use this to skip generation) (https://github.com/sknrsmkwcz/sql-lab-files/blob/main/cloudtrail_sim.csv)load_to_sqlite.py— loads the CSV into a SQLite database (https://github.com/sknrsmkwcz/sql-lab-files/blob/main/load_to_sqlite.py)
4. Generate the dataset (optional — skip if using the pre-generated CSV)
python3 generate_dataset.py
Expected output:
Generated 393 rows
Top 10 event types:
ListUsers: 57
GetSecretValue: 56
...
5. Load the database
python3 load_to_sqlite.py
Expected output:
✓ Loaded 393 rows into cloudtrail.db
6. Open SQLite and configure output formatting
sqlite3 cloudtrail.db
Then inside the SQLite shell, run these two formatting commands:
.mode column
.headers on
You should now be able to run:
SELECT COUNT(*) AS total_events FROM cloudtrail;
And see 393. You're ready.
Note: Every query below can be run directly in the SQLite shell. Exit at any time with
.quit.
Module 1 — SELECT: reading the log table
SELECT is how you read data from a table. The basic shape is:
SELECT column1, column2 FROM table_name;
The * wildcard selects all columns. LIMIT caps the number of rows returned — always use it when exploring an unfamiliar table.
Check the schema
Before querying, confirm what columns exist:
PRAGMA table_info(cloudtrail);
This returns the column names, types, and constraints — your map of the data.
Read the first 5 rows
SELECT * FROM cloudtrail LIMIT 5;
Select only the columns relevant for triage
SELECT event_time, username, event_name, source_ip, error_code
FROM cloudtrail
LIMIT 10;
Most error_code values will be empty — those are successful API calls. Non-empty values are where calls failed or were denied.
Count total events
SELECT COUNT(*) AS total_events FROM cloudtrail;
COUNT(*) counts all rows. AS total_events renames the output column. Result: 393 — your baseline for this 28-day window.
Module 2 — WHERE: filtering for suspicious events
WHERE adds a condition that every returned row must satisfy. This is how you cut noise — isolating failures, specific users, or known-bad IPs.
Show only failed events
SELECT event_time, username, event_name, source_ip, error_code
FROM cloudtrail
WHERE error_code != '';
!= means "not equal to". Empty error_code means success, so this returns only failures. You should see ~18 rows — all from one user, all Failed authentication. One account generating all authentication failures across 28 days is worth investigating.
Filter by user
SELECT event_time, event_name, source_ip, error_code
FROM cloudtrail
WHERE username = 'eve';
This returns eve's full 33-event history. Read it chronologically — you should be able to see the shape of an account compromise: rapid authentication failures, a successful login, then IAM enumeration.
Filter by time window
SELECT event_time, username, event_name, resource_arn
FROM cloudtrail
WHERE event_time >= '2024-06-18T02:00:00Z'
AND event_time <= '2024-06-18T04:00:00Z';
AND chains conditions — both must be true. This surfaces what happened between 2am and 4am on June 18th: 8 events from alice, all accessing company-data/payroll/.
Filter by IP
SELECT event_time, username, event_name, aws_region
FROM cloudtrail
WHERE source_ip = '185.220.101.45';
In a real SOC you'd often start from a known-bad IP (from a threat intel feed or an alert) and work backwards to what it touched. This returns all activity from that IP.
Combine conditions
SELECT event_time, username, event_name, error_code
FROM cloudtrail
WHERE username = 'eve'
AND event_name = 'ConsoleLogin'
AND error_code != '';
This isolates exactly 18 rows: eve's failed login attempts only. This is the core filter of a brute force detection rule.
Module 3 — GROUP BY and ORDER BY: turning events into patterns
GROUP BY collapses rows that share a value into a single summary row, so you can count or measure them. ORDER BY sorts the results. DESC sorts highest-first — useful for finding outliers.
A single failed login is noise. 18 from the same IP in 2 minutes is a brute force attack. GROUP BY makes that visible.
Count events per user
SELECT username, COUNT(*) AS event_count
FROM cloudtrail
GROUP BY username
ORDER BY event_count DESC;
Eve has the fewest total events but her 33 are concentrated in a short window and focused on IAM. Volume alone doesn't tell the story — but total event counts per user is always a useful baseline.
Count failed logins per user
SELECT username, COUNT(*) AS failed_logins
FROM cloudtrail
WHERE error_code != ''
GROUP BY username
ORDER BY failed_logins DESC;
Only eve appears — 18 failures, everyone else zero across the full 28-day window. One user accounting for 100% of authentication failures is a significant signal.
Behavioural fingerprint per user
SELECT username, event_name, COUNT(*) AS count
FROM cloudtrail
GROUP BY username, event_name
ORDER BY username, count DESC;
Grouping by two columns gives you a per-user profile. Scroll to eve — her top events are ConsoleLogin failures followed by IAM enumeration calls. No legitimate user's top activity is IAM enumeration.
Busiest source IPs
SELECT source_ip, COUNT(*) AS request_count
FROM cloudtrail
GROUP BY source_ip
ORDER BY request_count DESC;
Normal user IPs will cluster in the 80–100 range. In a real environment you'd join this output against a threat intel feed to flag known-bad IPs immediately.
Activity by hour of day
SELECT
strftime('%H', event_time) AS hour_of_day,
COUNT(*) AS event_count
FROM cloudtrail
GROUP BY hour_of_day
ORDER BY hour_of_day;
strftime('%H', ...) extracts the hour from a timestamp in 24h format. Most activity clusters in hours 08–17. The spikes at 02 and 03 are alice's payroll access and eve's brute force — both happening in the middle of the night.
Brute force threshold detection
SELECT username, source_ip, COUNT(*) AS failed_attempts
FROM cloudtrail
WHERE error_code != ''
GROUP BY username, source_ip
HAVING failed_attempts >= 5
ORDER BY failed_attempts DESC;
HAVING filters after grouping — you can't use WHERE to filter on COUNT(*) because the count doesn't exist until the group is formed. This returns only accounts where failed attempts crossed the threshold. Result: eve, 18 failures, one IP.
Module 4 — Subqueries: multi-step detection logic
A subquery is a SELECT nested inside another query. The inner query runs first and produces a result that the outer query uses. This lets you answer questions that require two steps — like "show me all activity from users who previously failed authentication."
All activity from users who had failed logins
SELECT event_time, username, event_name, source_ip
FROM cloudtrail
WHERE username IN (
SELECT DISTINCT username
FROM cloudtrail
WHERE error_code != ''
)
ORDER BY event_time;
DISTINCT removes duplicates from the inner result. The outer query returns the full history for any user who appears there — failures and successes combined. This gives you the complete attack timeline, not just the failed login phase.
Activity after a successful post-brute-force login
SELECT event_time, username, event_name, resource_arn
FROM cloudtrail
WHERE username = 'eve'
AND event_time > (
SELECT MIN(event_time)
FROM cloudtrail
WHERE username = 'eve'
AND event_name = 'ConsoleLogin'
AND error_code = ''
)
ORDER BY event_time;
MIN(event_time) returns the earliest matching timestamp. The outer query returns everything eve did after her first successful login — isolating the post-access phase from the brute force noise.
Users active outside business hours who also have daytime activity
SELECT DISTINCT username
FROM cloudtrail
WHERE strftime('%H', event_time) NOT IN (
'08','09','10','11','12','13','14','15','16','17'
)
AND username IN (
SELECT DISTINCT username
FROM cloudtrail
WHERE strftime('%H', event_time) IN (
'08','09','10','11','12','13','14','15','16','17'
)
);
The inner query identifies regular users — those with some business-hours activity. The outer query finds which of those users also appear outside business hours. This targets anomalous after-hours access by established users.
Result: alice. Note that eve doesn't appear here — her entire session was at 2am, so she has no business-hours baseline to compare against. Detection rules encode assumptions about attacker behaviour. A pure external actor with no daytime footprint evades this rule entirely.
Module 5 — Detection queries
Five detection queries built from the SQL you've learned. Each targets a named threat pattern with threshold logic credible in a SOC context.
Detection 1: brute force login attack
Threat: Repeated authentication failures from the same IP against the same account — automated credential stuffing or manual password guessing.
SELECT
username,
source_ip,
COUNT(*) AS failed_attempts,
MIN(event_time) AS first_attempt,
MAX(event_time) AS last_attempt
FROM cloudtrail
WHERE event_name = 'ConsoleLogin'
AND error_code != ''
GROUP BY username, source_ip
HAVING failed_attempts >= 5
ORDER BY failed_attempts DESC;
The timestamp spread matters as much as the count. 18 failures over 3 weeks could be a forgetful user. 18 failures in 144 seconds is automation. Both the count and the first_attempt/last_attempt spread should feed your alert logic.
Expected result: Eve — 18 failed attempts, first and last timestamps ~2 minutes apart.
Detection 2: privilege escalation via policy attachment
Threat: A user attaching an admin-level policy to their own account after initial access — a standard post-exploitation move to gain persistent elevated privileges.
SELECT
event_time,
username,
source_ip,
resource_arn,
user_agent
FROM cloudtrail
WHERE event_name = 'AttachUserPolicy'
AND resource_arn LIKE '%AdministratorAccess%'
ORDER BY event_time;
LIKE '%AdministratorAccess%' matches any resource_arn containing that string. This query has near-zero false positive rate in most environments — there is no operational reason a non-admin user should be calling AttachUserPolicy against the AdministratorAccess managed policy.
Expected result: One row — eve, 2:15am, attaching AdministratorAccess to herself.
Detection 3: post-compromise enumeration
Threat: Rapid broad enumeration of the AWS environment after gaining access — listing users, roles, buckets, functions, and databases to map what the compromised account can reach.
SELECT
username,
source_ip,
COUNT(DISTINCT event_name) AS unique_enum_calls,
MIN(event_time) AS window_start,
MAX(event_time) AS window_end
FROM cloudtrail
WHERE event_name IN (
'ListBuckets', 'ListUsers', 'ListRoles', 'ListPolicies',
'DescribeInstances', 'DescribeSecurityGroups', 'DescribeSubnets',
'ListFunctions', 'DescribeDBInstances', 'GetAccountAuthorizationDetails'
)
GROUP BY username, source_ip
HAVING unique_enum_calls >= 5
ORDER BY unique_enum_calls DESC;
The distinguishing feature of attacker enumeration isn't any single call — it's the breadth and speed. A legitimate admin might call 2–3 of these over a workday. Calling all 10 within 2 minutes is automated reconnaissance. COUNT(DISTINCT event_name) measures breadth, not just volume.
Expected result: Eve — 10 distinct enumeration call types, window spanning ~2 minutes.
Detection 4: after-hours access to sensitive resources
Threat: Access to sensitive data outside business hours — indicating either compromised credentials being used by an actor in a different timezone, or an insider threat operating outside normal patterns.
SELECT
event_time,
username,
source_ip,
event_name,
resource_arn
FROM cloudtrail
WHERE strftime('%H', event_time) NOT IN (
'06','07','08','09','10','11','12','13','14',
'15','16','17','18','19','20','21'
)
AND (
resource_arn LIKE '%payroll%'
OR resource_arn LIKE '%secret%'
OR resource_arn LIKE '%password%'
OR resource_arn LIKE '%AdministratorAccess%'
)
ORDER BY event_time;
The resource_arn patterns target high-value data: payroll files, secrets manager entries, stored credentials, and admin policy attachments. Tune the keyword list to match your environment's naming conventions.
Expected result: Alice's 8 payroll accesses at 3am, plus eve's AdministratorAccess attachment — two different threat scenarios caught by the same query.
Detection 5: impossible travel
Threat: The same user account authenticating successfully from two geographically distant IPs within a window too short for physical travel — indicating credential sharing, session theft, or account compromise.
SELECT
a.username,
a.source_ip AS ip_one,
a.event_time AS login_one,
b.source_ip AS ip_two,
b.event_time AS login_two,
ROUND(
(julianday(b.event_time) - julianday(a.event_time)) * 24 * 60,
1
) AS minutes_apart
FROM cloudtrail a
JOIN cloudtrail b
ON a.username = b.username
AND a.source_ip != b.source_ip
AND b.event_time > a.event_time
AND (julianday(b.event_time) - julianday(a.event_time)) * 24 * 60 <= 60
WHERE a.event_name = 'ConsoleLogin' AND a.error_code = ''
AND b.event_name = 'ConsoleLogin' AND b.error_code = ''
ORDER BY minutes_apart;
This uses a self-join — the cloudtrail table is joined against itself, aliased as a (first login) and b (second login). julianday() converts timestamps to decimal day numbers for arithmetic. The join condition matches pairs from the same user, different IPs, where the second login follows the first within 60 minutes.
Expected result: Bob — London IP and Tokyo IP — 4 minutes apart.
Key takeaways
WHEREfilters rows;GROUP BYaggregates them;HAVINGfilters the aggregated resultDISTINCTremoves duplicates;COUNT(DISTINCT column)measures breadth, not just volume- Subqueries let you use one query's output as another's input — essential for multi-step detection logic
- Self-joins let you compare rows within the same table — useful for session correlation and sequence detection
- Every detection rule encodes assumptions about normal vs. anomalous behaviour — document those assumptions alongside the query
Going further
- Translate these queries into AWS Athena SQL and run them against real CloudTrail logs in S3 — the syntax is largely compatible
- Explore Sigma rules{:target="_blank"} — a vendor-neutral detection rule format that maps directly to the query patterns you've written here
- Try building a false positive analysis: modify the dataset to add legitimate after-hours service account activity, then adjust the detection queries to exclude it without losing coverage