Time: ~2 hours, and about 30 minutes more for Azure Resource Graph · Difficulty: Beginner · You need: Docker with 4 GB of memory to spare and several gigabytes of disk, and Python 3. No Azure account for the walk-through. On an Apple silicon Mac, turn on Docker Desktop's Rosetta setting first (step 1); without Docker, use a free Azure Data Explorer cluster. The Azure Resource Graph part needs the Azure CLI, signed in to a subscription you can read.
How this page was checked: every command and query in the walk-through and in Exercises 1 to 4 was run as printed against the Kusto emulator (Azure Data Explorer engine 1.0.9766.21061, the latest image of 27 September 2026) in Docker Desktop on an Apple silicon Mac under Rosetta, and every output is pasted from that run. Not run: the free-cluster route, and the analytics rule inside a Microsoft Sentinel workspace. The rule's query runs here like every other, and the rule file uses only fields that Microsoft's own published CloudTrail rules use. The Azure Resource Graph part and Exercise 5 were run as printed on 4 November 2026 with Azure CLI 2.91.0 and its resource-graph extension 2.1.1, in zsh and in bash on macOS, against a subscription holding one storage account and no network security groups, with read-only queries; outputs are pasted from that run, with request timestamps and IDs replaced. Where that subscription had nothing to find, the page says so and shows the control. What it could not show comes from Microsoft's documentation and the CLI's source, not a run: the global --subscription option (the test identity could read only one subscription), subscriptions added after sign-in, how the CLI writes network rules, what older storage accounts hold, and what Key Vault accepts. Azure PowerShell's Search-AzGraph was not run.
One query language across Microsoft's security data
When a team that works in Microsoft's security tools needs to know what a leaked access key did, or which address kept being refused, someone writes a KQL query. Analysts in a security operations centre (SOC), the team that watches for attacks and responds to them, write it to hunt through logs. Detection engineers write it so that a security information and event management (SIEM) system, which gathers security logs in one place, can run it on a schedule and raise alerts.
Kusto is the query engine behind Azure Data Explorer, and KQL is the language you use to ask it questions. The same language runs in Azure Monitor Logs, in Microsoft Sentinel (Microsoft's SIEM, which keeps its data in Log Analytics workspaces), in advanced hunting in Microsoft Defender XDR, and in Microsoft Fabric, and a subset of it in Azure Resource Graph, which describes what is deployed rather than what happened (its own part, below).
One distinction matters most. A query only reads. It names a table, reshapes it and hands back a table, and it cannot change any data. A management command starts with a dot (.create, .ingest, .show) and changes or inspects the database itself. Commands belong to Azure Data Explorer and Fabric, which is why this lab can use a few of them to build its table. In Sentinel you never create AWSCloudTrail, the table that holds the logs of calls made to Amazon Web Services (AWS): Sentinel's AWS connectors, which read those logs from AWS, fill it, and you write only queries.
So the lab runs on the Kusto emulator, the Azure Data Explorer engine in a free Docker container, and builds a table with Sentinel's name for it, using Sentinel's column names and types. Every query operator and function used here is one Microsoft documents as available in Sentinel, so what you write carries across.
On this page
How to use this guide
Beginner here means new to KQL and to query languages. You should be comfortable typing commands into a terminal and have Docker installed. You do not need to know AWS or Azure for the walk-through: the AWS terms the lab depends on are explained as they come up, and terms with a glossary entry link to it.
Read it in order, because each part uses the one before. The next section explains how every query is built. Its example needs the table that step 3 builds, so read it first and run it once step 3 is done. In the walk-through, steps 1 to 3 start the Kusto engine in Docker, save kql.py, a small Python program that sends it your queries, load 24 CloudTrail events and give them the shape Sentinel stores them in. Steps 4 to 8 each teach one part of the language, and between them build four of the five detections listed at the top of the walk-through. Steps 5, 6 and 8 start with a query that looks right and returns the wrong answer without an error, then show why and fix it. Steps 9 and 10 turn one detection into a Sentinel analytics rule, Sentinel's name for a detection that runs on a schedule, and test it. The exercises then ask you to write the fifth detection and repair broken queries yourself, with each answer hidden until you click Show the answer.
A code block labelled BASH is something to paste into your terminal, and each block has a copy button. A block labelled KQL is a query, or a command if it starts with a dot: run it through kql.py, as step 1 explains, or paste it into the query editor if you use a free cluster instead. The unlabelled block under it, when there is one, shows what you should see. Some BASH blocks start with a line such as cat > kql.py <<'EOF', which writes every line down to the one that says only EOF into a file called kql.py. Paste the whole block at once, the EOF line included.
In the walk-through and Exercises 1 to 4, your output should match the page line for line: every command and query there was run as printed against the Kusto emulator, and the outputs are pasted from that run. The exception is .show version in step 1, which shows a newer build if Microsoft has published one since. The free-cluster route and the analytics rule inside a Sentinel workspace were not run.
The Azure Resource Graph part after the walk-through can wait for a second reading, and Exercise 5 goes with it. It needs the Azure CLI signed in to a subscription, and it asks about what is deployed in your own Azure account rather than about the lab. Its outputs are pasted from the subscription this page was checked against, so yours will differ wherever your subscription holds different things, and a few of its claims, listed in the info box, come from Microsoft's documentation and the CLI's source rather than a run. Its KQL blocks run through arg.py, a helper that its step 2 saves. Two smaller things can wait too: the paragraph at the end of step 1 on a free Azure Data Explorer cluster, which you need only if Docker will not run the emulator, and the 24 lines of lab data in step 2, which you can paste without reading.
If your output differs from the page, check that you are in ~/kql-lab, the folder step 1 creates, because kql.py and the lab files are there. If kql.py says it cannot reach the emulator, give the engine a few more seconds, as step 1 says. Then check step 2's count of 24 rows and 0 null events, and that you ran step 3, which builds the AWSCloudTrail table that every later query reads.
Tables in, tables out
Suppose you want to know which addresses made calls that failed, and how many each made. You would take every event, keep the ones that failed, count them per address, and put the biggest count first. A KQL query is written in that order, one step to a line.
A KQL query starts with the name of a table. Each line after that begins with a pipe, |, and one operator. The pipe hands the table so far to the operator; the operator does one job, such as dropping rows, adding a column or counting, and hands a new table on. The table you named is never changed, and whatever leaves the last operator is your result.
AWSCloudTrail // all 24 events | where isnotempty(ErrorCode) // the 10 that failed | summarize Failures = count() by SourceIpAddress // one row per address | sort by Failures desc // biggest first
SourceIpAddress | Failures -----------------+---------- 203.0.113.50 | 7 192.0.2.20 | 3 (2 rows)
Read it as four tables in a row (you can run it yourself once step 3 has built the table). Every prefix of a query is itself a complete query, so the quickest way to debug one is to delete operators from the bottom and run it again until the table stops surprising you. Two rules before you start: everything is case-sensitive (table names, column names, operators, functions), and // starts a comment.
Walk-through: one leaked key, five detections
The lab is 24 AWS CloudTrail events from one morning, trimmed to the fields this guide needs but keeping CloudTrail's real field names and nesting. The access key of build-bot, a continuous integration (CI) user, has leaked; an access key is the credential a program signs its AWS requests with. From 203.0.113.50 someone checks whose key it is, tries to list Identity and Access Management (IAM) users, Elastic Compute Cloud (EC2) instances and Secrets Manager secrets (and is mostly refused), then lists the Simple Storage Service (S3) buckets. Next they create a user, backup-svc, attach AdministratorAccess (AWS's ready-made policy that allows every action) to it, give it an access key, and use the new identity to stop CloudTrail logging. Around that runs an ordinary morning: sign-ins, one of them without multi-factor authentication (MFA) and one failed; an administrator creating a service account; an application refused the same S3 object three times; a CI role describing instances.
The walk-through builds five detections from these events:
- 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.
1. Start a Kusto engine
The Kusto emulator is Microsoft's Azure Data Explorer engine packaged as a Linux container, free and needing no Azure account. Microsoft supports it on Windows and Linux computers with an x86-64 processor (the Intel and AMD kind) and at least 2 GB of memory (4 GB recommended), not on Arm processors, and the image is several gigabytes. Setting ACCEPT_EULA=Y accepts Microsoft's licence terms for the emulator, so read them first; without it the container prints EULA Not Accepted and exits.
mkdir -p ~/kql-lab && cd ~/kql-lab docker run -d --name lab-kusto --platform linux/amd64 -e ACCEPT_EULA=Y -m 4G \ -p 8080:8080 mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest
On an Apple silicon Mac the engine runs under emulation, which Microsoft does not support. This page was checked that way, on macOS with Docker Desktop's Use Rosetta for x86_64/amd64 emulation on Apple Silicon setting turned on (Settings, General), and every step ran. It was not tried without that setting. --platform linux/amd64 asks for the only build Microsoft publishes, so Docker does not warn that it differs from your machine; on an x86-64 computer it changes nothing.
The emulator has no screen of its own. It answers on port 8080 with no sign-in and no encryption, so keep it on your own machine. Microsoft's clients (Kusto.Explorer, Kusto.CLI, the software development kits) can connect to it; to stay within Python's standard library, save this small client instead:
cat > kql.py <<'EOF'
# kql.py: send one query, or one command (it starts with a dot), to the
# emulator and print the result as a table. Usage: python3 kql.py < file.kql
import json, sys, urllib.request, urllib.error
text = sys.stdin.read().strip()
url = "http://localhost:8080/v1/rest/" + ("mgmt" if text.startswith(".") else "query")
body = json.dumps({"db": "NetDefaultDB", "csl": text}).encode()
headers = {"Content-Type": "application/json", "Accept": "application/json"}
try:
table = json.load(urllib.request.urlopen(urllib.request.Request(url, body, headers)))["Tables"][0]
except urllib.error.HTTPError as err: # the engine refused: say why
sys.exit(json.load(err)["error"]["@message"])
except urllib.error.URLError as err: # nothing listening (yet)
sys.exit(f"Cannot reach the emulator: {err.reason}")
show = lambda v: "" if v is None else v if isinstance(v, str) else json.dumps(v, separators=(",", ":"))
rows = [[c["ColumnName"] for c in table["Columns"]]] + [[show(v) for v in r] for r in table["Rows"]]
width = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
for n, row in enumerate(rows):
print((" " + " | ".join(v.ljust(w) for v, w in zip(row, width))).rstrip())
if n == 0:
print("+".join("-" * (w + 2) for w in width))
print(f"({len(rows) - 1} row{'' if len(rows) == 2 else 's'})")
EOFTo run anything on this page, save it in a file and run python3 kql.py < file.kql, or run python3 kql.py, paste, and press Ctrl-D. Check that the engine answers (if kql.py cannot reach it yet, give it a few more seconds):
.show version
BuildVersion | BuildTime | ServiceType | ProductVersion | ServiceOffering ----------------+----------------------+-------------+-------------------------------------+----------------- 1.0.9766.21061 | 2026-09-27T11:42:02Z | Engine | 2026.09.27.1134-2638-cb0a545-master | (1 row)
Your build will be newer if Microsoft has published one since. The emulator starts with one database, NetDefaultDB, and kql.py sends everything there.
No Docker, or the emulator will not start? Create a free Azure Data Explorer cluster. It needs a Microsoft account or a Microsoft Entra user identity, not an Azure subscription or a credit card, and Microsoft grants one free cluster for a year, for commercial or non-commercial use, with about 100 GB of storage. Run each KQL block on this page in its web query editor instead of through kql.py; step 2 notes the one difference.
2. Load the lab data
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}
EOFKusto stores data in typed columns. The simplest faithful load is a staging table (a table that holds raw data until it is reshaped) with one column of type dynamic, which holds any JSON (JavaScript Object Notation) value, one whole event per row:
.create table RawCloudTrail (Event: dynamic)
Now push the file in. format="json" means one JSON object per line, and the mapping says where the column's value comes from: the path $ is the whole event. Microsoft meant .ingest inline for experiments like this one, not for production.
cat > load.kql <<'EOF'
.ingest inline into table RawCloudTrail with (format="json", ingestionMapping='[{"Column":"Event","Properties":{"Path":"$"}}]') <|
EOF
cat cloudtrail-lab.jsonl >> load.kql
python3 kql.py < load.kqlOn a free cluster, paste the whole of load.kql into the query editor and run it. Then look inside what you loaded instead of trusting that the command succeeded:
RawCloudTrail | summarize Rows = count(), NullEvents = countif(isnull(Event))
Rows | NullEvents ------+------------ 24 | 0 (1 row)
A load can succeed and still be empty. Run the same .ingest without ingestionMapping and the engine reports no errors and 24 rows, and all 24 are null: nothing told it which part of each line belongs in Event. A row count proves that rows arrived, not that they hold anything.
3. Give it Sentinel's shape
Sentinel stores CloudTrail in a table called AWSCloudTrail, with its own column names: eventTime arrives as TimeGenerated, sourceIPAddress as SourceIpAddress, userIdentity.arn as UserIdentityArn. Microsoft's AWSCloudTrail table reference also types three columns as string, not dynamic: RequestParameters, ResponseElements and AdditionalEventData hold JSON as plain text. Build the same thing:
.set-or-replace AWSCloudTrail <|
RawCloudTrail
| project
TimeGenerated = todatetime(Event.eventTime),
EventName = tostring(Event.eventName),
SourceIpAddress = tostring(Event.sourceIPAddress),
UserIdentityArn = tostring(Event.userIdentity.arn),
UserIdentityUserName = tostring(Event.userIdentity.userName),
ErrorCode = tostring(Event.errorCode),
RequestParameters = tostring(Event.requestParameters),
ResponseElements = tostring(Event.responseElements),
AdditionalEventData = tostring(Event.additionalEventData)That command is a query with a destination. project chooses the output columns and names them; Event.eventName reaches into the dynamic value; todatetime() and tostring() give each column its type, and tostring() on a nested object produces its JSON text, which is what Sentinel stores. .set-or-replace saves the result as a table, replacing its rows if it exists, so running it twice is harmless.
These are the nine columns this page uses. The real table has more than 50, such as UserIdentityAccountId; a query that names one missing here fails with an error naming the column, so it cannot mislead you quietly. AWSCloudTrail | getschema lists a table's columns and their types.
4. Filter rows: the trail was stopped
AWSCloudTrail
| where EventName in ("StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors")
| project TimeGenerated, EventName, UserIdentityUserName, SourceIpAddressTimeGenerated | EventName | UserIdentityUserName | SourceIpAddress ----------------------+-------------+----------------------+----------------- 2026-09-15T10:09:03Z | StopLogging | backup-svc | 203.0.113.50 (1 row)
where keeps the rows for which its condition is true, and in tests a value against a list. These four calls stop a trail, delete it, or change what it records. One happened, from the attacker's address, made by the identity created minutes before, and the other 23 events stay out, which matters as much. == is exact and case-sensitive, =~ ignores case, and lists work the same way (in and in~). Searching inside text is where people slip:
AWSCloudTrail | summarize HasUser = countif(EventName has "User"), ContainsUser = countif(EventName contains "User")
HasUser | ContainsUser ---------+-------------- 0 | 4 (1 row)
countif() counts the rows where its condition is true. has matches a whole term: Kusto splits text into runs of letters and digits and indexes every term of three or more characters, which makes has fast. CreateUser is one term, so has "User" finds nothing, while in an Amazon Resource Name (ARN) slashes and hyphens split terms, so UserIdentityArn has "backup" does match user/backup-svc. contains matches any part of the text, ignoring case, and cannot use the index: it found both CreateUser events, ListUsers and AttachUserPolicy.
5. Reach into JSON text: a sign-in without MFA
Whether a console sign-in used MFA is recorded in additionalEventData.MFAUsed. In Sentinel's table that column is a string, and the dot only reaches into dynamic values, so AdditionalEventData.MFAUsed fails with a semantic error: a loud failure, which is the good kind. parse_json() turns the text into a dynamic value, tostring() turns the field you pull out back into text you can compare, and extend adds the result as a new column and keeps all the others:
AWSCloudTrail | where EventName == "ConsoleLogin" | extend MFAUsed = tostring(parse_json(AdditionalEventData).MFAUsed) | where MFAUsed == "No" | project TimeGenerated, UserIdentityUserName, SourceIpAddress, MFAUsed
TimeGenerated | UserIdentityUserName | SourceIpAddress | MFAUsed ----------------------+----------------------+-----------------+--------- 2026-09-15T09:15:47Z | bob | 198.51.100.21 | No 2026-09-15T09:20:03Z | alice | 192.0.2.77 | No (2 rows)
Two rows, and one of them is wrong. Alice's sign-in from 192.0.2.77 failed: there was no session for MFA to protect, and a rule that fires on it is noise. The outcome is in responseElements.ConsoleLogin:
AWSCloudTrail
| where EventName == "ConsoleLogin"
| extend MFAUsed = tostring(parse_json(AdditionalEventData).MFAUsed),
Outcome = tostring(parse_json(ResponseElements).ConsoleLogin)
| where Outcome == "Success" and MFAUsed == "No"
| project TimeGenerated, UserIdentityUserName, SourceIpAddress, Outcome, MFAUsedTimeGenerated | UserIdentityUserName | SourceIpAddress | Outcome | MFAUsed ----------------------+----------------------+-----------------+---------+--------- 2026-09-15T09:15:47Z | bob | 198.51.100.21 | Success | No (1 row)
Bob, and only Bob. The alarm AWS Security Hub CSPM checks for this (control CloudWatch.3) also requires a successful sign-in, and Microsoft's own AWS_ConsoleLogonWithoutMFA.yaml leaves failed ones out too. When a column is already dynamic, parse_json() passes the value through unchanged, so writing it costs nothing and the query works whichever type the column has.
6. Count, then threshold: a burst of refusals
A first draft of a rule for a leaked key being tried out: count AccessDenied errors per source address, and alert at five.
AWSCloudTrail | where ErrorCode == "AccessDenied" | summarize Denied = count() by SourceIpAddress | where Denied >= 5
SourceIpAddress | Denied -----------------+-------- (0 rows)
Nothing. A quiet morning, or a rule that cannot fire? Take the threshold away and look at what the data holds:
AWSCloudTrail | where isnotempty(ErrorCode) | summarize Events = count() by ErrorCode
ErrorCode | Events ------------------------------+-------- AccessDenied | 7 Client.UnauthorizedOperation | 3 (2 rows)
EC2 reports a refusal as Client.UnauthorizedOperation. The draft counted four of the attacker's seven refusals, one short of its own threshold. Other services spell a refusal differently again: the metric filter AWS Security Hub CSPM expects for unauthorized API calls (control CloudWatch.2) matches any code ending in UnauthorizedOperation or starting with AccessDenied, such as AccessDeniedException, and a refusal by a virtual private cloud (VPC) endpoint's policy is logged as VpceAccessDenied. Note isnotempty(), not isnull(): in Kusto a string cannot be null, so a missing errorCode arrives as an empty string and isnull(ErrorCode) is never true, here or in Sentinel.
A query that returns nothing and a query that cannot work return the same thing. Before you trust an empty result, remove the threshold or the filter and confirm that the rows you meant to count exist and look the way you assumed. Make that control part of testing every rule.
AWSCloudTrail | where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized" | summarize Denied = count(), Codes = make_set(ErrorCode) by SourceIpAddress | where Denied >= 5
SourceIpAddress | Denied | Codes -----------------+--------+------------------------------------------------- 203.0.113.50 | 7 | ["AccessDenied","Client.UnauthorizedOperation"] (1 row)
The attacker's address, and not the application's three refusals. Why contains and not a list: in with an exact list counts only the spellings you already know, which is how the draft failed, while the two words the codes share also catch prefixed and suffixed variants. has would not work here, because AccessDeniedException and UnauthorizedOperation are single terms that are not the terms AccessDenied or Unauthorized. The price is a scan instead of an index lookup, which is small for one table over a short window. make_set() puts the distinct codes in the result, so whoever reads the alert sees what was counted.
7. Time: ten-minute buckets
AWSCloudTrail | where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized" | summarize Denied = count() by SourceIpAddress, bin(TimeGenerated, 10m) | sort by SourceIpAddress asc, TimeGenerated asc
SourceIpAddress | TimeGenerated | Denied -----------------+----------------------+-------- 192.0.2.20 | 2026-09-15T09:30:00Z | 1 192.0.2.20 | 2026-09-15T09:40:00Z | 1 192.0.2.20 | 2026-09-15T10:00:00Z | 1 203.0.113.50 | 2026-09-15T10:00:00Z | 7 (4 rows)
bin() rounds each time down to a multiple of ten minutes, so rows fall into buckets aligned to the clock. All seven of the attacker's refusals land in the 10:00 bucket, within a minute of each other. The application's three are one per bucket across half an hour: something retrying, not someone exploring. For a fixed window, between includes both ends: where TimeGenerated between (datetime(2026-09-15 10:00) .. datetime(2026-09-15 10:10)).
8. Two events, one story: a new user given a key at once
Creating a user and immediately giving it an access key is a way to keep access after the leaked key is revoked. That is two events, so it needs a join. let gives a query a name you can use further down; each let ends with a semicolon.
let Created = AWSCloudTrail | where EventName == "CreateUser"
| project CreatedAt = TimeGenerated, CreatedBy = UserIdentityUserName, SourceIpAddress,
NewUser = tostring(parse_json(RequestParameters).userName);
let Keyed = AWSCloudTrail | where EventName == "CreateAccessKey"
| project KeyAt = TimeGenerated, NewUser = tostring(parse_json(RequestParameters).userName);
Created
| join kind=inner Keyed on NewUser
| project NewUser, CreatedAt, KeyAt, Gap = KeyAt - CreatedAt, CreatedBy, SourceIpAddressNewUser | CreatedAt | KeyAt | Gap | CreatedBy | SourceIpAddress ---------------+----------------------+----------------------+----------+-----------+----------------- backup-svc | 2026-09-15T10:06:12Z | 2026-09-15T10:06:51Z | 00:00:39 | build-bot | 203.0.113.50 svc-reporting | 2026-09-15T09:02:40Z | 2026-09-15T11:40:26Z | 02:37:46 | alice | 198.51.100.20 (2 rows)
join kind=inner pairs each row on the left with every row on the right that has the same NewUser. Both users match, and svc-reporting is alice doing her job: she created it at 09:02 and gave it a key more than two and a half hours later. The detection is the time window, a where after the join:
let Created = AWSCloudTrail | where EventName == "CreateUser"
| project CreatedAt = TimeGenerated, CreatedBy = UserIdentityUserName, SourceIpAddress,
NewUser = tostring(parse_json(RequestParameters).userName);
let Keyed = AWSCloudTrail | where EventName == "CreateAccessKey"
| project KeyAt = TimeGenerated, NewUser = tostring(parse_json(RequestParameters).userName);
Created
| join kind=inner Keyed on NewUser
| where KeyAt between (CreatedAt .. CreatedAt + 10m)
| project NewUser, CreatedAt, KeyAt, Gap = KeyAt - CreatedAt, CreatedBy, SourceIpAddressNewUser | CreatedAt | KeyAt | Gap | CreatedBy | SourceIpAddress ------------+----------------------+----------------------+----------+-----------+----------------- backup-svc | 2026-09-15T10:06:12Z | 2026-09-15T10:06:51Z | 00:00:39 | build-bot | 203.0.113.50 (1 row)
Thirty-nine seconds, by the leaked identity, from the attacker's address. Always write the kind. Leave it out and KQL uses innerunique, which keeps only one left-hand row for each key value, and Microsoft's documentation says which one is chosen at random: a user who signed in twice can quietly become a user who signed in once.
9. Make it a Sentinel analytics rule
In Sentinel a detection is a scheduled analytics rule: the query, plus how often to run it, how far back to look, when to alert, and what the alert is about. You can build one in the portal's rule wizard or deploy it as an Azure Resource Manager (ARM) template. Microsoft writes its own rules as YAML files, in the format its rule-authoring guide describes, and publishes them in the Azure-Sentinel repository's AWS analytic rules folder. That format is a good way to keep a rule under version control. Save step 6's query in the same shape; the id is a globally unique identifier (GUID) you generate once:
cat > AWS_RefusedCallBurst.yaml <<'EOF'
id: 200e45de-0a6e-4803-8137-248a3378a8dd
name: Burst of refused AWS API calls from one address
description: Identifies five or more refused AWS API calls from one source address.
severity: Medium
requiredDataConnectors:
- connectorId: AWS
dataTypes:
- AWSCloudTrail
- connectorId: AWSS3
dataTypes:
- AWSCloudTrail
queryFrequency: 10m
queryPeriod: 20m
triggerOperator: gt
triggerThreshold: 0
tactics:
- Discovery
relevantTechniques:
- T1580
- T1087.004
query: |
AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count(), Codes = make_set(ErrorCode) by SourceIpAddress
| where Denied >= 5
entityMappings:
- entityType: IP
fieldMappings:
- identifier: Address
columnName: SourceIpAddress
customDetails:
DeniedCalls: Denied
version: 1.0.0
kind: Scheduled
EOFqueryFrequencyandqueryPeriodare "run every" and "look back over", each from 5 minutes to 14 days. Sentinel runs the query over the lookback only, so it needs no time filter of its own, and starts each run five minutes after its scheduled time to allow for late events. A lookback twice the frequency means a burst lasting ten minutes or less falls wholly inside at least one run; the price is that a burst can be counted in two runs.triggerOperator: gtwithtriggerThreshold: 0means "alert when the query returns any rows". The trigger counts result rows, not events, so the count of five belongs in the query.requiredDataConnectorsnames both of Sentinel's AWS connectors, the older CloudTrail one (AWS) and the one that reads logs from an S3 bucket (AWSS3), because either can fillAWSCloudTrail.entityMappingstells Sentinel which column holds an address, so alerts and incidents about the same address can be grouped.customDetailsputs the count on the alert.tacticsandrelevantTechniquesmap the rule to MITRE ATT&CK, MITRE's knowledge base of adversary tactics and techniques: Discovery, with Cloud Infrastructure Discovery (T1580) and Account Discovery: Cloud Account (T1087.004). Sentinel accepts only its own list of tactic names, which the authoring guide ties to ATT&CK v16, so it lags ATT&CK's releases; Exercise 4 shows what that means for log tampering.
10. Test the rule both ways
The emulator has no scheduler, but you can count what two consecutive runs would see by writing their 20-minute windows by hand: the run at 10:00 looks back to 09:40, the run at 10:10 to 09:50.
AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize
RunAt1000 = countif(TimeGenerated between (datetime(2026-09-15 09:40) .. datetime(2026-09-15 10:00))),
RunAt1010 = countif(TimeGenerated between (datetime(2026-09-15 09:50) .. datetime(2026-09-15 10:10)))
by SourceIpAddressSourceIpAddress | RunAt1000 | RunAt1010 -----------------+-----------+----------- 192.0.2.20 | 1 | 1 203.0.113.50 | 0 | 7 (2 rows)
The 10:10 run sees the attacker's seven refusals and raises an alert; the 10:00 run sees one of the application's and none of the attacker's, and stays silent. Keep a check like this with the rule as its test, one window that must alert and one that must not: the second is what catches a rule that fires on everything.
Azure Resource Graph: KQL for what is deployed
Everything above asks about events: what happened, when, and from where. A security team also asks what exists now and how it is configured, the posture question, and in Azure that is what Azure Resource Graph answers. It indexes the resources Azure Resource Manager knows about, updated shortly after they change and by a regular full scan, and you query it in a subset of KQL. Microsoft describes it as a free service and throttles queries, and the recommendations of Microsoft Defender for Cloud and the compliance results of Azure Policy can be queried there too.
The data has a different shape from a log. There is no TimeGenerated: each row is one resource as it is now, with fixed columns such as id, name, type, location, resourceGroup, subscriptionId and tags, and its configuration in properties. Four mistakes return a short answer with no error, and they lead this part: a first page that looks complete, a scope smaller than you think, a setting that is absent rather than false, and an address in the field you did not check. You need the Azure command-line interface (CLI), signed in to a subscription you can read. Nothing here creates or changes anything.
1. Run a query, and read the fields around the rows
az extension add --name resource-graph
az graph query -q "Resources | summarize Resources = count() by type"
{
"count": 1,
"data": [
{
"Resources": 1,
"type": "microsoft.storage/storageaccounts"
}
],
"skip_token": null,
"total_records": 1
}The subscription this page was checked against holds one storage account, so the answer is short. The rows are in data; the fields around them describe the page you got: count rows in it, total_records in the whole result, and a skip_token when there is more to fetch. Read those before the rows. Note that type comes back in lower case. Resources is one of about forty tables Microsoft lists; the ones a security review reaches for first are:
Resources: most resource types and their configuration.ResourceContainers: subscriptions, resource groups and management groups.SecurityResources: Defender for Cloud's assessments, secure scores, alerts and regulatory compliance.PolicyResources: Azure Policy definitions, assignments and compliance states.AuthorizationResources: role assignments and role definitions.ResourceChanges: recent changes to resources.
Microsoft's query language page lists the tables, and every one it names was accepted on the day this page was checked. <table> | distinct type shows what one holds in your estate.
2. Pages: 100 rows unless you ask
The CLI asks for 100 rows unless you pass --first, which goes up to 1,000 (its source sets the 100). Beyond that you ask for the next page with the skip_token the last one returned. None of this is an error. FeatureResources, Azure's catalogue of preview features, runs to thousands of rows even in a nearly empty subscription, so it shows the effect:
az graph query -q "FeatureResources | project id | order by id asc" --query '{count: count, total_records: total_records, more: skip_token != `null`}' -o tableCount Total_records More ------- --------------- ------ 100 6580 True
A script that reads only data gets 100 rows and no hint of the other 6,480. Ask for the largest page and follow the token until More is False; here is the second page:
export Q="FeatureResources | project id | order by id asc"
TOKEN=$(az graph query -q "$Q" --first 1000 --query skip_token -o tsv)
az graph query -q "$Q" --first 1000 --skip-token "$TOKEN" --query '{count: count, total_records: total_records, more: skip_token != `null`}' -o table
az graph query -q "$Q | take 500" --query '{count: count, total_records: total_records, more: skip_token != `null`}' -o tableCount Total_records More ------- --------------- ------ 1000 6580 True Count Total_records More ------- --------------- ------ 100 500 False
The last command is the quiet one. A query that uses take or limit gets no skip token, so the other 400 rows cannot be paged to, and only total_records points to them. Sort before paging, as here: Microsoft warns that without a sort the rows returned are random and not repeatable. From here on, a helper does this checking for you: like kql.py, it reads one query, always asks for 1,000 rows, and prints the paging fields under the table.
cat > arg.py <<'EOF'
# arg.py: send one Resource Graph query (on stdin) through the Azure CLI, asking
# for the largest page, and print the rows as a table with the paging fields.
import json, subprocess, sys
run = subprocess.run(["az", "graph", "query", "--first", "1000", "-o", "json",
"-q", sys.stdin.read().strip()], capture_output=True, text=True)
if run.returncode:
sys.exit(run.stderr.strip())
result = json.loads(run.stdout)
show = lambda v: "" if v is None else v if isinstance(v, str) else json.dumps(v, separators=(",", ":"))
cols = list(result["data"][0]) if result["data"] else []
rows = [cols] + [[show(r.get(c)) for c in cols] for r in result["data"]]
width = [max(len(r[i]) for r in rows) for i in range(len(cols))]
for n, row in enumerate(rows if cols else []):
print((" " + " | ".join(v.ljust(w) for v, w in zip(row, width))).rstrip())
if n == 0:
print("+".join("-" * (w + 2) for w in width))
more = "more pages: follow skip_token" if result["skip_token"] else "no more pages"
print(f"({result['count']} of {result['total_records']} rows; {more})")
EOF3. Scope: what you can read, as the CLI saw it at sign-in
Unless you pass --subscriptions or --management-groups, the CLI sends the subscriptions it stored when you signed in (the list az account list reads), and Resource Graph answers for those you can read. Microsoft's overview says a subscription added since then stays out until the context is refreshed, which signing in again does; az account list --refresh also re-reads the list. The global --subscription option, which picks the subscription for most az commands, does not narrow this one: the extension's source builds its list without it. And a subscription ID you cannot read, here the all-zero one, is left out of the answer, not refused:
SUB=$(az account show --query id -o tsv) az graph query -q "ResourceContainers | where type == 'microsoft.resources/subscriptions' | count" --subscriptions "$SUB" 00000000-0000-0000-0000-000000000000 --query data -o table
Count ------- 1
Two subscriptions asked for, one counted, and no warning. Ask for the unreadable one alone and the service refuses:
az graph query -q "ResourceContainers | where type == 'microsoft.resources/subscriptions' | count" --subscriptions 00000000-0000-0000-0000-000000000000
ERROR: {
"code": "BadRequest",
"message": "Please provide below info when asking for support: timestamp = <time>, correlationId = <id>.",
"details": [
{
"code": "NoValidSubscriptionsInQueryRequest",
"message": "There must be at least one subscription that is eligible to contain resources. Given: '00000000-0000-0000-0000-000000000000'."
}
]
}Management groups show the same contrast: for the identity this page was checked with, ResourceContainers listed none and said nothing, while az account management-group list was refused with AuthorizationFailed. An empty answer means nothing you can read, which is not the same as nothing there, so count the subscriptions in the answer, as above, against the ones you expected.
4. Properties: the setting that is not there
A resource's configuration sits in properties, a dynamic column, so the dot reaches into it with no parse_json() (walk-through step 5 needed one because Sentinel stores its JSON as text). What it holds depends on the resource type, and a setting nobody changed is often absent rather than false. Microsoft documents that a storage account's allowSharedKeyAccess is not set by default and that the account accepts Shared Key authorization when it is null or true. Against an absent value, ==, < and > are all false (only != is true), so where properties.allowSharedKeyAccess == true skips exactly the accounts nobody configured. Before you filter on a property, count how often it is missing:
Resources
| where type == 'microsoft.storage/storageaccounts'
| extend Days = toint(properties.keyPolicy.keyExpirationPeriodInDays)
| summarize Accounts = count(), Over90 = countif(Days > 90), AtMost90 = countif(Days <= 90),
NoKeyPolicy = countif(isnull(properties.keyPolicy)),
SharedKeyUnset = countif(isnull(properties.allowSharedKeyAccess)),
PublicUnset = countif(isnull(properties.allowBlobPublicAccess))Accounts | AtMost90 | NoKeyPolicy | Over90 | PublicUnset | SharedKeyUnset ----------+----------+-------------+--------+-------------+---------------- 1 | 0 | 1 | 0 | 0 | 0 (1 of 1 rows; no more pages)
Run it with python3 arg.py < file.kql, or paste it after python3 arg.py and press Ctrl-D. The CLI sorts each row's fields by name, which is why the columns are alphabetical. The one account here sets both access settings but has no keyPolicy, its reminder to rotate its access keys, and the two threshold columns show what that does: it is neither over 90 days nor at most 90, so a rule written either way passes it by. Write the documented default into the query, as in coalesce(tobool(properties.allowSharedKeyAccess), true), or test isnull() beside the value.
allowBlobPublicAccess is less settled. Microsoft's API specification described the default interpretation of an unset value as true up to API version 2022-09-01 and as false from 2023-01-01, but its remediation guide still says an account can show it blank in Resource Graph, and the audit policy in that guide flags every account where it is not false, absent included. Some settings are not meant to be false at all: Key Vault's API does not accept false for enablePurgeProtection, so look for vaults where it is not true.
5. Network rules: the address in the other field
A network security group (NSG) rule names its source in sourceAddressPrefix, one value, or in sourceAddressPrefixes, an array, and destinations and ports have the same pair. When the Azure CLI creates a rule, it puts one address in the singular field and two or more in the array, so a check of sourceAddressPrefix alone misses any rule that lists the internet among other ranges. This query reads both, after turning each group's securityRules array into one row per rule:
Resources
| where type == 'microsoft.network/networksecuritygroups'
| mv-expand rule = properties.securityRules limit 2000
| where rule.properties.access == 'Allow' and rule.properties.direction == 'Inbound'
| mv-expand Source = array_concat(pack_array(rule.properties.sourceAddressPrefix),
rule.properties.sourceAddressPrefixes) to typeof(string)
| where Source in ('*', '0.0.0.0/0', 'Internet')
| project Nsg = name, Rule = tostring(rule.name), Source(0 of 0 rows; no more pages)
mv-expand stops after 128 elements of each array unless you give it a limit, and does not say so; a group can hold 2,000 rules, which is also the largest limit Resource Graph accepts. The subscription behind this page has no network security groups (step 1), so the query has nothing to find. To watch it work, give it two rules in the shape Resource Manager returns, written inline with dynamic() on one row of any table:
ResourceContainers
| take 1
| mv-expand rule = dynamic([
{'name': 'ssh-any', 'properties': {'access': 'Allow', 'direction': 'Inbound',
'sourceAddressPrefix': '*'}},
{'name': 'rdp-list', 'properties': {'access': 'Allow', 'direction': 'Inbound',
'sourceAddressPrefixes': ['198.51.100.0/24', '0.0.0.0/0']}}])
| where rule.properties.access == 'Allow' and rule.properties.direction == 'Inbound'
| mv-expand Source = array_concat(pack_array(rule.properties.sourceAddressPrefix),
rule.properties.sourceAddressPrefixes) to typeof(string)
| where Source in ('*', '0.0.0.0/0', 'Internet')
| project Rule = tostring(rule.name), Source, SingularField = tostring(rule.properties.sourceAddressPrefix)
| order by Rule ascRule | SingularField | Source ----------+---------------+----------- rdp-list | | 0.0.0.0/0 ssh-any | * | * (2 of 2 rows; no more pages)
A check of the singular field reads only SingularField, which is empty for rdp-list, the rule that admits 0.0.0.0/0.
6. What the language leaves out
Resource Graph runs a subset of KQL, and unlike the four failures above, a missing piece fails loudly. In the test for this page it refused let (write the subquery inside the join instead), the anti and semi joins (kind=leftanti fails with UnsupportedJoinFlavor; use kind=leftouter and keep the rows whose right-hand columns are empty), and getschema, search, datatable, range, mv-apply, lookup and make-series. Microsoft documents the join kinds innerunique (still the default), inner, leftouter and fullouter, and some tables cannot be joined in the same query. Table names are not case-sensitive here, but == still is.
Hands-on exercises
The attacker gave backup-svc administrator rights (D5). Write a query that finds any attachment of a policy named AdministratorAccess, whether to a user, a group or a role, and shows who did it, to what, and when.
Show the answer
AWSCloudTrail
| where EventName in ("AttachUserPolicy", "AttachGroupPolicy", "AttachRolePolicy")
| extend Params = parse_json(RequestParameters)
| extend PolicyArn = tostring(Params.policyArn),
Target = coalesce(tostring(Params.userName), tostring(Params.groupName),
tostring(Params.roleName))
| where PolicyArn endswith "/AdministratorAccess"
| project TimeGenerated, UserIdentityUserName, EventName, Target, PolicyArnTimeGenerated | UserIdentityUserName | EventName | Target | PolicyArn ----------------------+----------------------+------------------+------------+--------------------------------------------- 2026-09-15T10:06:30Z | build-bot | AttachUserPolicy | backup-svc | arn:aws:iam::aws:policy/AdministratorAccess (1 row)
Parse the JSON once into a column, then pull fields from it. The target's name sits in a different field for each call, and coalesce() returns the first argument that is not null, or for strings not empty: the empty-string rule from step 6 working in your favour. endswith matches the AWS managed policy and any customer policy given the same name, and both are worth a look.
A colleague's version of the burst query buckets time in ten-minute steps that start at three minutes past (a rule that runs at :03, :13 and :23 splits time the same way). It returns nothing, although the seven refusals are still in the data. What happened, and how do you prove it?
AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count()
by SourceIpAddress, Bucket = bin_at(TimeGenerated, 10m, datetime(2026-09-15 10:03))
| where Denied >= 5SourceIpAddress | Bucket | Denied -----------------+--------+-------- (0 rows)
Show the answer
Run the control: the same query with the threshold removed.
AWSCloudTrail
| where ErrorCode contains "AccessDenied" or ErrorCode contains "Unauthorized"
| summarize Denied = count()
by SourceIpAddress, Bucket = bin_at(TimeGenerated, 10m, datetime(2026-09-15 10:03))
| sort by SourceIpAddress asc, Bucket ascSourceIpAddress | Bucket | Denied -----------------+----------------------+-------- 192.0.2.20 | 2026-09-15T09:23:00Z | 1 192.0.2.20 | 2026-09-15T09:43:00Z | 1 192.0.2.20 | 2026-09-15T09:53:00Z | 1 203.0.113.50 | 2026-09-15T09:53:00Z | 3 203.0.113.50 | 2026-09-15T10:03:00Z | 4 (5 rows)
The burst straddles a bucket boundary. Three refusals fell before 10:03:00 and four after, and neither bucket reaches five. Nothing about the attack changed, only where the boundaries fell. The rule in step 9 therefore counts over its whole lookback instead of binning inside it, and uses a lookback longer than its frequency.
Another colleague looks for the stopped trail over the last day, gets a count of zero, and concludes that nobody touched it. What went wrong, and what should they have run first?
AWSCloudTrail | where TimeGenerated > ago(1d) | where EventName == "StopLogging" | count
Count ------- 0 (1 row)
Show the answer
ago() counts back from the engine's clock, and these events happened on 15 September 2026, so the time filter removed every one of them. The control is to ask the data what time it covers:
AWSCloudTrail | summarize Earliest = min(TimeGenerated), Latest = max(TimeGenerated)
Earliest | Latest ----------------------+---------------------- 2026-09-15T08:55:12Z | 2026-09-15T11:40:26Z (1 row)
On lab data, use fixed times instead. Sentinel adds more layers of the same trap: the Log Analytics time picker shows the last 24 hours unless your query sets its own range, and a scheduled rule only ever sees its lookback. And if you sent these lab events to a real Log Analytics workspace, Azure Monitor would replace every TimeGenerated more than two days older than its arrival with the arrival time, so their dates would not survive.
Turn the trail query from step 4 into a Sentinel rule (D1). What goes in tactics and relevantTechniques, and what should the query add so that it alerts only when the change succeeded?
Show the answer
Sentinel still uses ATT&CK's pre-v19 names, so the mapping it accepts is the one in Microsoft's own AWS_LogTampering.yaml:
tactics: - DefenseEvasion relevantTechniques: - T1562.008
AWSCloudTrail
| where EventName in ("StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors")
| where isempty(ErrorCode)
| project TimeGenerated, EventName, UserIdentityUserName, SourceIpAddressisempty(ErrorCode) keeps only calls that succeeded, so this returns step 4's single row: the StopLogging call went through. Microsoft's library puts refused attempts in a separate, low-severity rule, AWS_ClearStopChangeTrailLogs.yaml. In ATT&CK v19.2 this behaviour is T1685.002 Disable or Modify Cloud Log, under the new Defense Impairment tactic (TA0112): T1562.008 is revoked, and TA0005, which Sentinel still calls DefenseEvasion, is renamed Stealth. Neither new name is in Sentinel's tactic list yet, and Microsoft's content checks reject T1685.002 because no tactic they know contains it. Map to what your platform accepts, and expect the mapping to move when it catches up.
Before a review, a colleague counts storage accounts in Resource Graph and gets zero, although Resource Graph step 1 found one. What happened, and what should they have run first?
Resources | where type == 'Microsoft.Storage/storageAccounts' | count
Count ------- 0 (1 of 1 rows; no more pages)
Show the answer
Ask the data how it spells the value you are filtering on, with no filter at all. That is step 1's question:
Resources | summarize Resources = count() by type
Resources | type -----------+----------------------------------- 1 | microsoft.storage/storageaccounts (1 of 1 rows; no more pages)
Resource Graph returns type in lower case and == is case-sensitive, so the filter matched nothing, and a query that could not work printed the same zero as an empty estate. Use =~, which ignores case (walk-through step 4), or copy the value as the control printed it. Property names are case-sensitive too: properties.AllowSharedKeyAccess is null even on an account that sets allowSharedKeyAccess, which is Resource Graph step 4's absent setting by another route.
Common mistakes
- Reading
hasas "contains".hasmatches whole terms, andCreateUseris one term. Usecontainsfor part of a word. - Testing a string with
isnull(). Kusto strings are never null; a missing value is empty. Useisempty()andisnotempty(). - Trusting
!=to mean "has a different value".ErrorCode != "AccessDenied"also keeps every event that has no error code at all. - Counting one spelling of an outcome. A rule that counts only
AccessDeniedmisses EC2'sClient.UnauthorizedOperation, under-counts, and stays silent. - Treating Sentinel's JSON columns as objects.
RequestParameters,ResponseElementsandAdditionalEventDataare text. The dot fails loudly; searching the raw text withhasorcontainsfails quietly, because it can match the word in any field. - Leaving out
kind=on a join. The default,innerunique, keeps one left-hand row per key and drops the rest without saying so. - Filtering with
ago()before checking the data's dates. Old data, lab data and a time picker set to the last 24 hours all return nothing. Runmin(TimeGenerated)andmax(TimeGenerated)first. - Binning inside a scheduled rule. A bucket boundary can split a burst so that neither half reaches the threshold. Count over the whole lookback.
- Believing a load because it reported rows. JSON ingested without a mapping arrives as rows of nulls. Look inside before you query.
- Taking the CLI's first page for the answer.
az graph queryreturns 100 rows unless you pass--first(at most 1,000). Comparecountwithtotal_recordsand followskip_token; a query ending intakeorlimitgets no token at all. - Reading an empty Resource Graph answer as "none exist". It covers only the subscriptions the CLI stored at sign-in, and of those only the ones you can read; the rest drop out without a word.
- Filtering on a property that may be absent.
== true,== falseand> 90all skip a resource that lacks the property. Count the absent ones first, and write the documented default into the query. - Checking only
sourceAddressPrefix. A rule with several addresses keeps them insourceAddressPrefixes. Read both, and givemv-expandalimit, or it stops at 128 rules.
Where next
- Sigma and YARA, for writing a detection once in a vendor-neutral format and converting it for a SIEM such as Sentinel.
- Splunk SPL (Search Processing Language) and Elastic ES|QL and EQL (Elasticsearch Query Language and Event Query Language), the same five detections on the same 24 events in other query languages.
- CloudWatch Logs Insights and Cloud Logging queries, the same lab in AWS's and Google Cloud's own log query languages, which play the part in those clouds that KQL plays in Azure's Log Analytics.
- Azure Policy, whose compliance results are among the rows in Resource Graph's
PolicyResourcestable. - Detection engineering for the lifecycle around a rule: testing, tuning, versioning and retiring it.
- The CloudTrail to SIEM lab, to send real CloudTrail from your own account somewhere these queries can run.
- Microsoft's common tasks with KQL for Microsoft Sentinel and the Azure-Sentinel repository, where reading Microsoft's own rules is the quickest way to learn the idioms.
