Time: ~2 hours · Difficulty: Beginner · You need: a Falcon LogScale repository where you may add a parser and an ingest token (step 1 covers the options, including Next-Gen SIEM), and a terminal with curl and jq. Terraform is optional (step 10).
How this page was checked: CrowdStrike offers no way to run LogScale without an account: sign-ups to the free Community Edition closed with release 1.134 in April 2024, self-hosted LogScale needs a licence key bought from CrowdStrike, and the hosted services need a tenant. So none of the CQL queries on this page were run. Every function, parameter, default and limit was checked against CrowdStrike's documentation (library.humio.com, current to LogScale 1.265, and developer.crowdstrike.com) on 4 November 2026, and the results described are what those documented rules give for the lab file, worked out by a local script rather than captured from LogScale. The shell steps were run as printed, in bash and zsh on macOS, against a local stand-in for the ingest address, and the correlation rule in step 10 passed terraform validate with Terraform 1.15.7 and CrowdStrike's provider 1.2.1 from the Terraform registry. It was never applied.
One language, two CrowdStrike products
Falcon LogScale is CrowdStrike's log management platform. It was called Humio until late 2022, which is why its documentation lives at library.humio.com. Its query language is the CrowdStrike Query Language (CQL), which older pages call the LogScale Query Language. Falcon Next-Gen SIEM searches with the same language, over CrowdStrike's own sensor data and third-party logs alike, and CrowdStrike's developer documentation describes its behavioural rules as using LogScale's correlate() function.
Analysts type CQL into the search page (Advanced event search in Next-Gen SIEM) to investigate. Detection engineers save the queries worth repeating as triggers in LogScale, or as correlation rules in Next-Gen SIEM. A query is plain text, so it can live in git and be reviewed like code, which step 10 does.
The idea to hold on to: CQL has two small languages inside it. In a filter, such as eventName="StopLogging", the left side is a field and the right side is always a value. In an expression, inside test() or after :=, a bare word is a field name and a value needs quotes. Mixing the two up, and CQL's unusual rule that OR binds tighter than AND, cause most of the silent failures on this page.
On this page
Filters, then functions, joined by pipes
LogScale stores each event as its original text, in a field called @rawstring, plus the named fields a parser created when the event arrived, plus @timestamp, the time the event happened. With the parser this page uses, nested JSON (JavaScript Object Notation) becomes dotted names: the userName inside userIdentity is the field userIdentity.userName.
A query is a list of steps joined by the pipe character, |, and each step works on whatever the step before it passed on. A step is either a filter, which keeps the events that match, or a function, which can filter, reshape, count or join them. The smallest query is one filter:
eventName="StopLogging"
That returns the one event whose eventName is exactly StopLogging. The = compares the whole value and is case-sensitive, so "stoplogging" finds nothing; a * in the value is a wildcard. Quotes are optional around a plain word and required around spaces, special characters or keywords, and this page quotes every value. A bare word with no field, such as StopLogging on its own, is a free-text search across every field. Add a function to choose what to show:
eventName="StopLogging" | table([@timestamp, userIdentity.userName, sourceIPAddress])
Filters written side by side on one line must all match, as if joined by AND. Now combine two conditions with or, intending "console logins without multi-factor authentication (MFA), or any StopLogging":
eventName="ConsoleLogin" additionalEventData.MFAUsed="No" or eventName="StopLogging"
OR binds tighter than AND. CrowdStrike's operator reference says so, and notes that this differs from most programming languages. The query above is valid, and reads as eventName="ConsoleLogin" AND (MFAUsed="No" OR eventName="StopLogging"), so it returns two console logins and never the StopLogging event. Parentheses say what you meant: (eventName="ConsoleLogin" additionalEventData.MFAUsed="No") or eventName="StopLogging" returns all three.
Walk-through
The lab is 24 Amazon Web Services (AWS) CloudTrail events. A continuous integration user's access key, build-bot, has leaked. From 203.0.113.50 someone checks who the key belongs to, tries to list Identity and Access Management (IAM) users, Amazon Elastic Compute Cloud (EC2) resources and secrets, and is mostly refused. They then create a user, backup-svc, give it administrator rights and an access key, and use it to stop CloudTrail logging. Around that sits ordinary activity, including a console login without MFA and an application repeatedly denied one file. The walk-through builds five detections from it:
- D1, trail tampering: CloudTrail logging stopped, deleted or changed.
- D2, denied-call burst: one source address refused five or more times.
- D3, new user given a key: a user created and handed an access key within 10 minutes.
- D4, console login without MFA: a successful console sign-in without multi-factor authentication.
- D5, administrator policy attached:
AdministratorAccessattached to a user.
All times on this page are UTC (Coordinated Universal Time).
1. Get somewhere to run CQL
There is nothing to install. You need one of these:
- A repository in LogScale Cloud or a self-hosted LogScale, where your role has the Change parsers and Change Ingest tokens permissions. An empty repository of your own is ideal, so that nothing else gets counted. The free Community Edition no longer takes new sign-ups (LogScale 1.134 removed them), and self-hosted LogScale runs on a licence key that CrowdStrike sells.
- A Falcon Next-Gen SIEM tenant where you may add a data connector. Its HEC/HTTP Event Data Connector (HEC is HTTP Event Collector, HTTP the Hypertext Transfer Protocol) receives data over HTTP and lets you select or create the parser, and Advanced event search runs the queries below. Step 3's upload is written for LogScale, so follow CrowdStrike's connector guide for the sending side.
2. Create a parser and an ingest token
In LogScale, events sent with an ingest token pass through the parser assigned to that token. In your repository, open Parsers, click New parser, name it cloudtrail-lab, and give it this code:
parseJson() | parseTimestamp(field=eventTime)
parseJson() reads the raw line as JSON and turns every key into a field. parseTimestamp() reads eventTime into @timestamp; its default format is ISO 8601, the international standard for dates and times, which 2026-09-15T08:55:12Z matches. Then open Settings, Ingest tokens, click + Add token, name it, set Assigned Parser to cloudtrail-lab, and save. Copy the token, and treat it like a password: anyone who has it can send data into the repository, though not read it.
A parser can get every field right and the time wrong. If no parser sets @timestamp, LogScale gives each event the time it arrived, so all 24 lab events would carry the second they were uploaded, and every time window on this page would be wrong. D3's 10-minute window, for one, could no longer tell the attacker's 39 seconds from alice's 2 hours 37 minutes. The built-in json parser is not the shortcut it looks like: it expects a field called @timestamp, which CloudTrail does not have. Step 4 checks for this.
3. Load the lab data
mkdir -p ~/logscale-lab && cd ~/logscale-lab
cat > cloudtrail-lab.jsonl <<'EOF'
{"eventTime":"2026-09-15T08:55:12Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"Yes"}}
{"eventTime":"2026-09-15T09:02:40Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"readOnly":false}
{"eventTime":"2026-09-15T09:03:05Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"readOnly":true}
{"eventTime":"2026-09-15T09:10:22Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T09:15:47Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.21","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/bob","userName":"bob"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:20:03Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.77","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"errorMessage":"Failed authentication","responseElements":{"ConsoleLogin":"Failure"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:31:10Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T09:47:55Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:01:30Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:05Z","eventSource":"sts.amazonaws.com","eventName":"GetCallerIdentity","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:31Z","eventSource":"iam.amazonaws.com","eventName":"ListUsers","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:38Z","eventSource":"iam.amazonaws.com","eventName":"ListRoles","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:52Z","eventSource":"iam.amazonaws.com","eventName":"GetAccountAuthorizationDetails","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:05Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:09Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeVpcs","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:14Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeSecurityGroups","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:22Z","eventSource":"secretsmanager.amazonaws.com","eventName":"ListSecrets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:40Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:06:12Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:30Z","eventSource":"iam.amazonaws.com","eventName":"AttachUserPolicy","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc","policyArn":"arn:aws:iam::aws:policy/AdministratorAccess"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:51Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"responseElements":{"accessKey":{"userName":"backup-svc","accessKeyId":"AKIAIOSFODNN7EXAMPLE","status":"Active"}},"readOnly":false}
{"eventTime":"2026-09-15T10:09:03Z","eventSource":"cloudtrail.amazonaws.com","eventName":"StopLogging","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/backup-svc","userName":"backup-svc"},"requestParameters":{"name":"arn:aws:cloudtrail:us-east-1:111122223333:trail/org-trail"},"readOnly":false}
{"eventTime":"2026-09-15T10:40:18Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T11:40:26Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"responseElements":{"accessKey":{"userName":"svc-reporting","accessKeyId":"AKIAI44QH8DHBEXAMPLE","status":"Active"}},"readOnly":false}
EOF
shasum -a 256 cloudtrail-lab.jsonl
jq -c '(now - 86400 | strftime("%Y-%m-%d")) as $d | .eventTime |= ($d + .[10:])' \
cloudtrail-lab.jsonl > upload.jsonl
jq -R . upload.jsonl | jq -s '[{messages: .}]' > upload.json
LOGSCALE_URL='https://paste-your-logscale-address-here'
INGEST_TOKEN='paste-your-ingest-token-here'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer $INGEST_TOKEN" -H 'Content-Type: application/json' \
--data-binary @upload.json "$LOGSCALE_URL/api/v1/ingest/humio-unstructured"The cat line writes everything up to EOF into cloudtrail-lab.jsonl, and shasum prints the file's hash so you can check the copy is exact. Expect 35b7c80582d0650ec4850acf84f84fa4b8e5d37169ac3f7276c76f29b67c3319, then 200. The first jq line moves the lab to yesterday; only the date changes, so every time of day and every gap between events stays the same. That matters because a repository's retention applies to @timestamp, and CrowdStrike's documentation warns that events older than the retention period are ingested and expired at once. The second wraps each line as one message in the body that the unstructured ingest endpoint expects. Use the address of your LogScale (the Ingest tokens page shows a separate ingest address if your cluster has one). Do not post the file to /api/v1/ingest/raw instead: that endpoint reads the whole request body as a single event.
4. Check what arrived
Set the search time range to the last two days, then count what arrived and whether the parser failed on any of it:
// How many events did each parser handle, and how many failed? groupBy(#type, function=[count(as=events), count(@error, as=errors)])
Expect a row for cloudtrail-lab with 24 events and 0 errors. #type records which parser handled an event, and a parser that fails adds @error to the event. Fewer than 24 means lines were lost; 48 means you uploaded twice. Any other row is other data in the repository, which every query below would also count; if you cannot use an empty repository, start each query with #type="cloudtrail-lab". Then check that the times came from eventTime:
// Did each event's time come from eventTime, or from its arrival? timeSource := if(@timestamp == @ingesttimestamp, then="arrival", else="eventTime") | groupBy(timeSource)
Expect one row: eventTime 24. When a parser cannot set @timestamp, it equals @ingesttimestamp, the arrival time, so a row for arrival means step 2 went wrong. := creates a field from an expression, and if() picks one of two values. The comparison has to be an expression: the filter @timestamp = @ingesttimestamp would not compare the two fields, because the right side of a filter is always a value, never a field.
5. Filter on a field: trail tampering (D1)
// D1: CloudTrail logging stopped, deleted or changed in(field="eventName", values=["StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors"]) | table([@timestamp, eventName, userIdentity.userName, sourceIPAddress])
Expect one row, at 10:09:03: StopLogging by backup-svc from 203.0.113.50. The other 23 events are the negative case, and none appears. in() keeps events whose field matches any value in the list; values may contain *, and matching is case-sensitive unless you add ignoreCase=true. table() shows the fields you name, newest first.
6. Nested fields and the naive rule: console logins without MFA (D4)
The login result and the MFA flag sit one level down, in responseElements.ConsoleLogin and additionalEventData.MFAUsed. Start with the obvious rule:
// D4, naive: console logins that did not use MFA eventName="ConsoleLogin" additionalEventData.MFAUsed="No" | table([@timestamp, userIdentity.userName, sourceIPAddress, responseElements.ConsoleLogin])
Expect two rows: alice at 09:20:03 from 192.0.2.77 with result Failure, and bob at 09:15:47 from 198.51.100.21. The first is someone failing to sign in as alice: nobody got in, and the failed login carries MFAUsed No as well. The detection needs the result too:
// D4: successful console logins without MFA eventName="ConsoleLogin" responseElements.ConsoleLogin="Success" additionalEventData.MFAUsed="No" | table([@timestamp, userIdentity.userName, sourceIPAddress])
Expect bob only. The negative cases are alice's 08:55:12 login, which used MFA, and the failed attempt the naive rule reported. Write MFAUsed="no" and the query returns nothing, because = is case-sensitive.
7. Count, then threshold: the burst that never fires (D2)
// D2, naive: AccessDenied per source address, 5 or more errorCode="AccessDenied" | groupBy(sourceIPAddress) | _count >= 5
groupBy() makes one row per source address. Its default function is count(as=_count), so each row carries the number of events in _count, and the last line is a filter on that number. No results. Delete the last line and you see why: 203.0.113.50 has 4 and 192.0.2.20 has 3. Did nothing happen, or can the query not see what happened? Run the control: drop the filter and ask which error codes exist at all.
// Control: which error codes are in the data at all? default(field=errorCode, value="(none)") | groupBy(errorCode)
Expect (none) 14, AccessDenied 7 and Client.UnauthorizedOperation 3. default() gives events with no errorCode a value of their own, so they show as a row instead of being left for you to infer. EC2 reports a refusal as Client.UnauthorizedOperation, so three of the attacker's seven denials were invisible to the rule. Other AWS services answer with AccessDeniedException (CloudTrail's own documentation shows a refused stop-logging call returning it), and AWS's own control for unauthorized API (application programming interface) calls, CloudWatch.2 in Security Hub CSPM (cloud security posture management), matches two families, AccessDenied* and *UnauthorizedOperation. in() takes the same two wildcard patterns:
// D2: every denial code, per source address, 5 or more in(field="errorCode", values=["AccessDenied*", "*UnauthorizedOperation"]) | groupBy(sourceIPAddress) | _count >= 5
Expect one row: 203.0.113.50 with 7. Without the last line, 192.0.2.20 shows 3 and stays under the threshold, which is the negative case.
A query that returns nothing and a query that cannot work return the same thing. Before you trust an empty result, run a control that must return something: the same query without its threshold, or a count of the values that actually exist.
8. Time windows
The time range is not part of a query. You set it in the search page's time selector, in an API call, or in a trigger's settings, so the same text runs over the last hour or the last month. setTimeInterval(start=2d) writes the range into the query text instead, and overrides the selector. To count per period inside the range, bucket() groups by time as well as by the fields you name:
// D2 in 10-minute buckets in(field="errorCode", values=["AccessDenied*", "*UnauthorizedOperation"]) | bucket(span=10m, field=sourceIPAddress)
Each row has _bucket, the bucket's start time in milliseconds, the address and _count. Expect 203.0.113.50 with all 7 in the 10:00 bucket, and 192.0.2.20 once each in 09:30, 09:40 and 10:00. Buckets start on clock boundaries, not at the first event, so a burst that straddled 10:10 would be split in two and could stay under a threshold in both halves. bucket() also keeps only 10 series (here, addresses) unless you set limit, up to 500; on real data the busiest ten win and the rest are dropped with a warning.
9. One user, two events, ten minutes (D3)
"A new user was given an access key within 10 minutes" is about two events. correlate() takes named queries, each in braces, and finds groups of events, one per query, that satisfy the links between them. <=> declares a link: here, the key's requestParameters.userName must equal the created user's.
// D3: a new user given an access key within 10 minutes
correlate(
created: { eventName="CreateUser" } include: [userIdentity.userName, sourceIPAddress],
keyed: { eventName="CreateAccessKey" | requestParameters.userName <=> created.requestParameters.userName },
sequence=true,
within=10m
)Expect one row, for backup-svc: created at 10:06:12 by build-bot from 203.0.113.50, keyed 39 seconds later at 10:06:51. Each field in the row is prefixed with its query's name, such as created.sourceIPAddress and keyed.requestParameters.userName. sequence=true requires the events in the order the queries are written, and within=10m limits the span from the first event to the last. Now run the control: delete within=10m (and the comma before it). Two rows come back, backup-svc and svc-reporting, alice's routine work, keyed 2 hours 37 minutes after she created the user. The window is what keeps her out.
The queries inside correlate() may filter but not aggregate, correlate() cannot follow an aggregate function, and by default each event of the first query is matched at most once (maxPerRoot=1).
10. Turn the searches into detections
In LogScale a saved detection is a trigger: run the query on the search page, click Save and choose Trigger. There are four types, and CrowdStrike's guidance picks by query: a filter alert for a plain filter such as D1, a scheduled search for a query with joins, and otherwise an aggregate alert, such as D2 (aggregate alerts do not support bucket(), timeChart() or join functions). D3 goes in a scheduled search, which runs an ordinary query on a schedule: correlate()'s reference lists the contexts it runs in, and aggregate and filter alerts are not among them. On self-hosted LogScale, scheduled searches run only once an administrator sets ENABLE_SCHEDULED_SEARCHES to true, which is not the default.
The setting to read twice is the timestamp. Scheduled searches and aggregate alerts run on @ingesttimestamp by default, so an event counts when it arrives. Switch a scheduled search to @timestamp and an event that arrives after its window has been searched is missed, unless you set Delay run to allow for it; AWS puts CloudTrail's typical delivery at about five minutes after the call, on average, and does not guarantee it. The default has its own trap: CrowdStrike warns that old data loaded into a repository with triggers can look current and fire them.
In Next-Gen SIEM the same query becomes a correlation rule, and CrowdStrike's documentation says its behavioural rules use correlate(). This is D3 as code, for CrowdStrike's provider for Terraform (a tool that creates resources from text files):
data "crowdstrike_cid" "this" {}
resource "crowdstrike_correlation_rule" "new_user_given_key" {
name = "CloudTrail: new IAM user given an access key within 10 minutes"
cid = data.crowdstrike_cid.this.cid
description = "CreateUser, then CreateAccessKey for the same user, within 10 minutes."
severity = "high"
status = "inactive"
search = {
filter = <<-EOT
correlate(
created: { eventName="CreateUser" } include: [userIdentity.userName, sourceIPAddress],
keyed: { eventName="CreateAccessKey" | requestParameters.userName <=> created.requestParameters.userName },
sequence=true,
within=10m
)
EOT
lookback = "30m"
trigger_mode = "verbose"
use_ingest_time = true
}
schedule = {
interval = "10m"
start_on = "2026-12-01T09:00:00Z"
}
mitre_attack = [
{ tactic_id = "TA0003", technique_id = "T1136.003" },
{ tactic_id = "TA0003", technique_id = "T1098.001" },
]
notifications = [
{
type = "email"
is_guardrail = true
recipients = ["soc@example.com"]
},
]
}The rule runs every 10 minutes over the last 30, on ingest time. CrowdStrike asks for a search window at least as long as the frequency, and with overlap a pair can fall inside more than one run's window. verbose raises one detection per result row, and start_on must be at least 15 minutes in the future when you create the rule. At least one notification must be a guardrail, for a reason: the platform switches off a rule that produces too many outcomes (50, for a rule that runs more than once a day), and the guardrail notification is how you hear about it. One more difference: CrowdStrike's own example rule for CloudTrail matches #Vendor="aws", #event.module="cloudtrail" and event.action="AttachUserPolicy", not eventName, because CrowdStrike's parsers follow the CrowdStrike Parsing Standard (CPS), which renames fields to a shared schema. A query copied from this lab finds nothing on such data until you check a sample event and rename its fields.
The rule carries MITRE ATT&CK (Adversarial Tactics, Techniques, and Common Knowledge) identifiers as numbered in ATT&CK v19.2, which v20.0 kept unchanged for every technique on this page: T1136.003 Create Account: Cloud Account and T1098.001 Additional Cloud Credentials, under Persistence (TA0003). Tag the others the same way: D1 with T1685.002 Disable or Modify Cloud Log under Defense Impairment (TA0112); D2 with T1087.004 and T1580; D4 with T1078.004; D5 with T1098.003.
11. Test the alert both ways
At least 10 minutes after step 3's upload, so that the upload has left the window, save the fixed D2 query from step 7 as an aggregate alert with a 10-minute time window. Then replay the relevant events stamped with the current time. First the negative case, the reports-app role's three denials, which must not fire it:
jq -c --arg ip 192.0.2.20 '(now | strftime("%Y-%m-%dT%H:%M:%SZ")) as $t
| select(.sourceIPAddress == $ip and has("errorCode")) | .eventTime = $t' \
cloudtrail-lab.jsonl | jq -R . | jq -s '[{messages: .}]' > replay.json
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer $INGEST_TOKEN" -H 'Content-Type: application/json' \
--data-binary @replay.json "$LOGSCALE_URL/api/v1/ingest/humio-unstructured"Expect 200, and no alert. Then run the same two commands with --arg ip 203.0.113.50, which replays the attacker's seven denials: the alert should fire with one row, 203.0.113.50 with 7. For the full picture, save the naive query from step 7 as a second alert and replay again: it stays silent, which is how the bug hides in production. The replays stay in the repository with today's date, so for the exercises below set the time range to cover only the lab's own date, the day before you uploaded it.
Hands-on exercises
Write D5: find every time the AdministratorAccess managed policy was attached to a user, showing who attached it, to whom, and from where.
Show the answer
// D5: AdministratorAccess attached to a user eventName="AttachUserPolicy" requestParameters.policyArn="arn:aws:iam::aws:policy/AdministratorAccess" | table([@timestamp, userIdentity.userName, requestParameters.userName, sourceIPAddress])
Expect one row at 10:06:30: build-bot attached it to backup-svc from 203.0.113.50. Both userIdentity and requestParameters contain a key called userName, and the dotted names keep the actor and the target apart. For real coverage, also consider AttachRolePolicy, AttachGroupPolicy and inline policies written with PutUserPolicy, each of which grants the same power by another route.
This query returns nothing against the lab. Why?
test(eventName == StopLogging)
Show the answer
Inside test(), an unquoted word is a field name, so this compares the field eventName with a field called StopLogging, which no event has. A missing field gives a null value, the comparison gives null, and no event passes. Quote the value: test(eventName == "StopLogging") returns the one event. In a filter the rule is the reverse, which is why eventName=StopLogging works without quotes.
A colleague writes D3's link with a plain comparison instead of <=>, as keyed: { eventName="CreateAccessKey" | requestParameters.userName = created.requestParameters.userName }. The query runs and finds nothing. Explain.
Show the answer
Inside a query node, requestParameters.userName = created.requestParameters.userName is an ordinary filter, and the right side of a filter is a value: it looks for a user literally named "created.requestParameters.userName". No event has one, so the second query matches nothing and no pair can form. CrowdStrike's correlate() reference shows the same mistake with != and says <=> is the only operator that links events.
Which source addresses were used by more than one identity? Count distinct values with count(field, distinct=true). Then explain what goes wrong if you count userIdentity.userName instead of the identity's Amazon Resource Name (ARN).
Show the answer
// How many identities used each source address? groupBy(sourceIPAddress, function=count(userIdentity.arn, distinct=true, as=identities)) | sort(identities)
Expect six rows, 203.0.113.50 first with 2 (the leaked build-bot key and the backup-svc user it created) and the other five addresses with 1 each. sort() puts the largest first unless you add order=asc.
Roles have no userName, and count() with a field counts only events that have it. Count userIdentity.userName and the ci-deploy role's address 192.0.2.10 and the reports-app role's 192.0.2.20 both show 0 identities: the table still has six rows and looks complete, but two addresses appear to have been used by nobody.
Common mistakes
- Believing an empty result. A misspelt field, a value in the wrong case, a precedence slip and a quiet account all return the same nothing. Remove the threshold, or count the values that exist, and see what comes back.
- Expecting AND to bind tighter than OR. In CQL it is the other way round. Put parentheses around every group you mean.
- Comparing two fields with
=. The right side of a filter is always a value. Compare fields insidetest(), and link events incorrelate()with<=>. - Leaving values unquoted in an expression. Inside
test()and after:=, a bare word is a field name, and a missing field is a silent null. - Assuming matches ignore case.
=andin()are case-sensitive. UseignoreCase=trueinin(), or a regular expression with/i. - Trusting the parser without checking
@timestamp. A parser that never sets it stamps everything with the upload time, and old events past the repository's retention expire on arrival. - Reading a capped result as complete. A query with no aggregate shows at most 200 events,
table()andsort()keep 200 rows by default,bucket()10 series, andgroupBy()20,000 groups, past which its counts are only a lower bound. - Picking a trigger's timestamp without thinking about delay. On
@timestamp, late events miss their window; on@ingesttimestamp, old events loaded today look new. - Ignoring the guardrail notification. A Next-Gen SIEM correlation rule that produces too many outcomes is switched off, and the guardrail notification is how you find out.
Where next
- Splunk's Search Processing Language (SPL) and Kusto Query Language (KQL) run the same lab and the same five detections, a good way to see which ideas are CQL's and which are universal.
- Sigma and YARA for writing a detection once and converting it, and jq and JMESPath for the
jqused in the load steps. - The CloudTrail to SIEM lab for a real CloudTrail feed to point these queries at, and detection engineering for testing, tuning and retiring them.
- CrowdStrike's CQL documentation, its page of common misconceptions, and the
correlate()reference. - CrowdStrike's community content repository, with queries, dashboards and parsers for LogScale and Next-Gen SIEM, and its guide to correlation rules.
