New IRQL functions covering the new AADGraphActivityLogs table that Microsoft started populating broadly in 2026. These functions encapsulate the legacy Azure AD Graph API (graph.windows.net) telemetry behind the same primitive shapes (Get_*, Extract_*, graph-lifted Extract_Node_* variants, external enrichment) the rest of IRQL uses, plus an external enrichment against OAuthSentry for OAuth Application ID classification. These functions were created in collaboration with Saar Ron, John Lambert, and Diana Damenova.
Entra ID has shipped two parallel Graph APIs for most of the last decade. Azure AD Graph (graph.windows.net) launched in 2013 and is what the AzureAD PowerShell module, the older MSOnline cmdlets, and a long tail of in-house tooling actually talk to. Microsoft announced its retirement in 2019, with the deprecation timeline extended several times since. Microsoft Graph (graph.microsoft.com) is the modern replacement, the API every new app is supposed to use.
Functionally they overlap a lot. A token for one will usually get you the same data from the other. The new AADGraphActivityLogs table extends Defender telemetry coverage to the legacy API surface, complementing MicrosoftGraphActivityLogs (live since late 2023) for the modern API. Tools that exercise graph.windows.net heavily — ROADtools' roadrecon gather, AADInternals' Azure AD cmdlets — now produce request-by-request records in this table, and combined with MicrosoftGraphActivityLogs and SigninLogs you have coverage of programmatic identity-plane access across both APIs in one workspace.
The full schema is documented here. Field-level details that motivate the primitives below:
SignInActivityIdis the bridge toSigninLogs.UniqueTokenIdentifier, but AAD Graph stores it with trailing==padding while SigninLogs strips it. Joins fail silently if you don't normalize - the extractor in section 2 handles this.SessionIdcorrelates a single user session acrossSigninLogs,AADGraphActivityLogs,MicrosoftGraphActivityLogs, and the Unified Audit Log. It's the most useful tenant-wide pivot key in the new table.CallerIpAddressis a real IP for delegated (user) flows, but a Microsoft-owned IP for service-principal flows. IP-based hunting has to account for that.ActorTypeis the fastest hot-path filter (UservsApplication) - the first cut in nearly every hunt.RequestUricarries the actual legacy endpoint hit (/users,/groups,/devices,/serviceprincipals, ...) and is the foundation for ROADrecon-style "shopping list" detections.AppIdidentifies the OAuth application making the request - and is the key the OAuthSentry enrichment in section 4 joins against.
Everything below leans on these.
Two new primitives, one per Graph API. The modern-Graph selector is included so cross-table queries that span legacy and modern Graph (and SigninLogs via SignInActivityId / SessionId) read as a single dialect rather than three different schemas.
The _All variants follow the IRQL convention strictly: only project-rename and project-reorder, never a down-projecting project. Every column the underlying table carries is preserved; the only changes are name normalization (TimeGenerated → EnvTime, CallerIpAddress / IPAddress → ClientIp, AADTenantId → TenantId) and column ordering so the investigation-relevant fields surface first. The non-_All variants down-project to the unified IRQL shape.
| Function | Returns |
|---|---|
Get_Event_AadGraphActivity |
Legacy AAD Graph events, down-projected to EnvTime, ClientIp, AppId, ServicePrincipalId, UserId, RequestMethod, RequestUri, ResponseStatusCode, UserAgent, ActorType |
Get_Event_AadGraphActivity_All |
Full legacy Graph events - every column preserved, with IRQL-standard names |
Get_Event_MicrosoftGraphActivity |
Modern Microsoft Graph events, down-projected to the same shape |
Get_Event_MicrosoftGraphActivity_All |
Full modern Graph events - every column preserved, with IRQL-standard names |
.create-or-alter function with (folder="IRQL", docstring="Legacy Azure AD Graph (graph.windows.net) request events, projected to the unified IRQL schema")
Get_Event_AadGraphActivity() {
AADGraphActivityLogs
| project
EnvTime = TimeGenerated,
ClientIp = CallerIpAddress,
AppId,
ServicePrincipalId,
UserId,
RequestMethod,
RequestUri,
ResponseStatusCode,
UserAgent,
ActorType
}
.create-or-alter function with (folder="IRQL", docstring="Legacy Azure AD Graph events, full schema preserved with IRQL-standard column names. No columns dropped.")
Get_Event_AadGraphActivity_All() {
AADGraphActivityLogs
| project-rename
EnvTime = TimeGenerated,
ClientIp = CallerIpAddress,
TenantId = AADTenantId
| project-reorder
EnvTime,
ClientIp,
AppId,
ServicePrincipalId,
UserId,
RequestMethod,
RequestUri,
ResponseStatusCode,
UserAgent,
ActorType,
Scopes,
Roles,
SignInActivityId,
SessionId,
DurationMs,
ResponseSizeBytes,
TokenIssuedAt,
ClientAuthMethod,
IdentityProvider,
DeviceId,
Wids,
Location,
OperationName,
TenantId,
RequestId
}
.create-or-alter function with (folder="IRQL", docstring="Modern Microsoft Graph (graph.microsoft.com) request events, projected to the unified IRQL schema")
Get_Event_MicrosoftGraphActivity() {
MicrosoftGraphActivityLogs
| project
EnvTime = TimeGenerated,
ClientIp = IPAddress,
AppId,
ServicePrincipalId,
UserId,
RequestMethod,
RequestUri,
ResponseStatusCode,
UserAgent,
ActorType = iff(isnotempty(UserId), "User", "Application")
}
.create-or-alter function with (folder="IRQL", docstring="Modern Microsoft Graph events, full schema preserved with IRQL-standard column names. ActorType is computed (the underlying table doesn't store it); no other columns dropped.")
Get_Event_MicrosoftGraphActivity_All() {
MicrosoftGraphActivityLogs
| extend ActorType = iff(isnotempty(UserId), "User", "Application")
| project-rename
EnvTime = TimeGenerated,
ClientIp = IPAddress,
TenantId = AADTenantId
| project-reorder
EnvTime,
ClientIp,
AppId,
ServicePrincipalId,
UserId,
RequestMethod,
RequestUri,
ResponseStatusCode,
UserAgent,
ActorType,
Scopes,
Roles,
SignInActivityId,
SessionId,
DurationMs,
ResponseSizeBytes,
TokenIssuedAt,
ClientAuthMethod,
IdentityProvider,
TenantId,
RequestId
}Note on the modern-Graph _All: MicrosoftGraphActivityLogs doesn't carry an ActorType column natively the way AADGraphActivityLogs does, so it's computed via extend before the renames. This is an addition, not a down-projection - every column from the underlying table survives.
Usage: the schema-drift point from the parent IRQL gist applies in microcosm here - AAD Graph calls the IP CallerIpAddress, MS Graph calls it IPAddress. The selectors hide that. A query that wants "all Graph traffic from this IP across both APIs" reads the same way against both:
union
(Get_Event_AadGraphActivity),
(Get_Event_MicrosoftGraphActivity)
| where ClientIp == "203.0.113.42"
| summarize Hits = count() by ClientIp, AppId, UserAgent
| sort by Hits descThree new transforms. Each one is grounded in a concrete hunting pattern called out in the Invictus writeup or the Microsoft sample queries.
| Function | Input | Adds | Why |
|---|---|---|---|
Extract_AadGraph_Resource(T) |
RequestUri:string |
TopLevelResource |
The "shopping list" pattern - ROADrecon hits every top-level legacy resource (users, groups, tenantdetails, serviceprincipals, ...) in a tight window. You can't summarize that without normalizing RequestUri to its top-level path component first. |
Extract_AadGraph_TargetId(T) |
RequestUri:string |
TargetObjectId |
When a request hits /users/{guid} rather than /users, the GUID is the object the caller dereferenced. Useful for pivoting into "who got enumerated, in detail" - distinct from the bulk listing case. |
Extract_SignInActivityId_Unpadded(T) |
SignInActivityId:string |
SignInActivityIdUnpadded |
Microsoft stores the same value with == padding in AADGraphActivityLogs and without padding in SigninLogs.UniqueTokenIdentifier. Joins fail silently if you don't strip. |
URI parsing uses parse_url rather than ad-hoc split chains. parse_url returns a dynamic with Path, Query, Host, Scheme, etc., so query-string stripping is implicit (the Path field already excludes anything after ?) and the extractor expresses what it actually means: "give me the first segment of the path."
.create-or-alter function with (folder="IRQL", docstring="Extracts the top-level AAD Graph resource (users, groups, devices, ...) from RequestUri using parse_url for query-string-safe path handling")
Extract_AadGraph_Resource(T:(RequestUri:string)) {
T
| extend TopLevelResource = tolower(tostring(split(tostring(parse_url(RequestUri).Path), "/")[2]))
}
.create-or-alter function with (folder="IRQL", docstring="Extracts the GUID of a specific target object from AAD Graph RequestUri (e.g. /users/{guid}). Empty when the request hits a collection rather than a specific object.")
Extract_AadGraph_TargetId(T:(RequestUri:string)) {
T
| extend TargetObjectId = extract(@"/(?:users|groups|devices|serviceprincipals|applications|directoryroles|contacts)/([0-9a-fA-F-]{36})", 1, tostring(parse_url(RequestUri).Path))
}
.create-or-alter function with (folder="IRQL", docstring="Strips trailing '=' padding from SignInActivityId so it can be joined against SigninLogs.UniqueTokenIdentifier")
Extract_SignInActivityId_Unpadded(T:(SignInActivityId:string)) {
T
| extend SignInActivityIdUnpadded = trim_end("=+", SignInActivityId)
}A note on the path index: AAD Graph URIs are tenant-scoped (/{tenantId}/users, /{tenantId}/groups, ...), so the first segment of the path (split(...)[1] after the leading /) is the tenant GUID and the second segment (split(...)[2]) is the top-level resource. The parse_url(...).Path field starts with a leading /, which makes split[0] empty, split[1] the tenant ID, and split[2] the resource - the index in the function reflects that.
Usage:
Get_Event_AadGraphActivity
| invoke Extract_AadGraph_Resource()
| summarize Hits = count() by TopLevelResource
| sort by Hits descThe tabular extractors in section 2 derive a new column from an existing one on a row table. Their graph-lifted twins do the same job against the unified graph table produced by Lift_To_Graph: they read a value from an existing node property, compute the derived value, and write it back into the node's properties bag in place. The graph shape (nodes, edges) is unchanged - the targeted nodes just get richer property bags and, optionally, a refreshed nodeDisplayName.
This is the same Extract_Node_* pattern already used in the parent IRQL gist for Extract_Node_Email_Sender_Domain, Extract_Node_Employee_Firstname, and Extract_Node_Event_Network_Domain. The new functions extend that surface to the AAD Graph extractors so the section-2 transforms are equally usable from a tabular hunt and a graph investigation.
| Function | Reads from properties |
Adds to properties |
|---|---|---|
Extract_Node_AadGraph_Resource(T, newDisplayName) |
RequestUri |
TopLevelResource |
Extract_Node_AadGraph_TargetId(T, newDisplayName) |
RequestUri |
TargetObjectId |
Extract_Node_SignInActivityId_Unpadded(T, newDisplayName) |
SignInActivityId |
SignInActivityIdUnpadded |
The newDisplayName parameter is consistent with the existing Extract_Node_* / Enrich_Node_* surface: pass the name of any property carried on the node (including the one just added) to relabel the node with that value, or pass an empty string to leave nodeDisplayName alone.
.create-or-alter function with (folder="IRQL", docstring="Graph-lifted Extract_AadGraph_Resource. Reads RequestUri from each node's property bag, computes TopLevelResource via parse_url, and writes it back. Optionally re-labels the node using newDisplayName.")
Extract_Node_AadGraph_Resource(
T:(EntityType:string, id:string, type:string, properties:dynamic, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string, SourceId:string, TargetId:string, edgeType:string, edgeProperties:dynamic, edgeDisplayName:string, edgeColor:string),
newDisplayName:string=""
) {
let Targeted =
T
| where EntityType == "node"
| where isnotempty(tostring(properties["RequestUri"]))
| extend TopLevelResource = tolower(tostring(split(tostring(parse_url(tostring(properties["RequestUri"])).Path), "/")[2]))
| extend properties = bag_merge(properties, bag_pack("TopLevelResource", TopLevelResource))
| extend nodeDisplayName = iff(isnotempty(newDisplayName), strcat(type, "/", tostring(properties[newDisplayName])), nodeDisplayName)
| project-away TopLevelResource;
let Untouched =
T
| where EntityType != "node" or isempty(tostring(properties["RequestUri"]));
union Targeted, Untouched
}
.create-or-alter function with (folder="IRQL", docstring="Graph-lifted Extract_AadGraph_TargetId. Reads RequestUri from each node's property bag, extracts the {guid} of a targeted directory object (when present), and writes it back as TargetObjectId.")
Extract_Node_AadGraph_TargetId(
T:(EntityType:string, id:string, type:string, properties:dynamic, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string, SourceId:string, TargetId:string, edgeType:string, edgeProperties:dynamic, edgeDisplayName:string, edgeColor:string),
newDisplayName:string=""
) {
let Targeted =
T
| where EntityType == "node"
| where isnotempty(tostring(properties["RequestUri"]))
| extend TargetObjectId = extract(@"/(?:users|groups|devices|serviceprincipals|applications|directoryroles|contacts)/([0-9a-fA-F-]{36})", 1, tostring(parse_url(tostring(properties["RequestUri"])).Path))
| extend properties = bag_merge(properties, bag_pack("TargetObjectId", TargetObjectId))
| extend nodeDisplayName = iff(isnotempty(newDisplayName), strcat(type, "/", tostring(properties[newDisplayName])), nodeDisplayName)
| project-away TargetObjectId;
let Untouched =
T
| where EntityType != "node" or isempty(tostring(properties["RequestUri"]));
union Targeted, Untouched
}
.create-or-alter function with (folder="IRQL", docstring="Graph-lifted Extract_SignInActivityId_Unpadded. Reads SignInActivityId from each node's property bag, strips trailing '=' padding, and writes it back as SignInActivityIdUnpadded so it can be joined against SigninLogs.UniqueTokenIdentifier.")
Extract_Node_SignInActivityId_Unpadded(
T:(EntityType:string, id:string, type:string, properties:dynamic, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string, SourceId:string, TargetId:string, edgeType:string, edgeProperties:dynamic, edgeDisplayName:string, edgeColor:string),
newDisplayName:string=""
) {
let Targeted =
T
| where EntityType == "node"
| where isnotempty(tostring(properties["SignInActivityId"]))
| extend SignInActivityIdUnpadded = trim_end("=+", tostring(properties["SignInActivityId"]))
| extend properties = bag_merge(properties, bag_pack("SignInActivityIdUnpadded", SignInActivityIdUnpadded))
| extend nodeDisplayName = iff(isnotempty(newDisplayName), strcat(type, "/", tostring(properties[newDisplayName])), nodeDisplayName)
| project-away SignInActivityIdUnpadded;
let Untouched =
T
| where EntityType != "node" or isempty(tostring(properties["SignInActivityId"]));
union Targeted, Untouched
}A few notes on the shape:
- In-place, not structural. These are
Extract_Node_*, notEnrich_Graph_*. They don't lift new nodes or edges into the graph - they just enrich the bags on nodes that already exist. That's what makes them the direct graph-lifted twin of the section-2 extractors. - The source property has to actually be on the node. The functions filter to
EntityType == "node"and to nodes carrying the source property (RequestUriorSignInActivityId). Untargeted rows - edges, and nodes whosepropsmapping didn't include the source property - flow through unchanged via theUntouchedbranch. In practice that means yourLift_To_Graphmapping has to list the source column in the relevant node type'spropsarray; otherwise there's nothing to read from. bag_merge, notbag_pack. The merge preserves existing properties on the bag and only adds the new one. Replacing the bag withbag_packwould clobber everything else the node carries.- Display-name update is opt-in. The default
newDisplayName=""leavesnodeDisplayNamealone. Pass"TopLevelResource"(or any other property carried on the node) to relabel - useful when folding follows, since the fold key reads better as a label than the underlying ID.
The tabular ROADrecon detection in the end-to-end examples below ranks users by how much of the legacy directory they walked. The graph form of the same hunt makes the structure visible: each implicated user/app at the center, with the set of top-level resources they touched fanning out around it, and shared infrastructure clusters showing up immediately under Graph_Fold_By_Property.
let RoadreconMapping = todynamic(```{
"node_types":[
{"type":"User","id":"User","key":"UserId","props":["UserId"]},
{"type":"App","id":"App","key":"AppId","props":["AppId"]},
{"type":"Request","id":"Req","key":"RequestId","props":["RequestId","RequestUri","RequestMethod","ResponseStatusCode","EnvTime"]},
{"type":"Ip","id":"Ip","key":"ClientIp","props":["ClientIp"]}
],
"edges":[
{"type":"MadeRequest","source":{"id":"User","type":"User"},"target":{"id":"Req","type":"Request"},"props":["EnvTime"]},
{"type":"AsApp","source":{"id":"Req","type":"Request"},"target":{"id":"App","type":"App"},"props":["EnvTime"]},
{"type":"FromIp","source":{"id":"Req","type":"Request"},"target":{"id":"Ip","type":"Ip"},"props":["EnvTime"]}
]
}```);
Get_Event_AadGraphActivity_All
| where EnvTime > ago(1d)
| where RequestMethod == "GET"
| where isnotempty(UserId)
| project EnvTime, ClientIp, AppId, UserId, RequestMethod, RequestUri, ResponseStatusCode, RequestId
| invoke Lift_To_Graph(RoadreconMapping)
| invoke Extract_Node_AadGraph_Resource("TopLevelResource")
| invoke Graph_Fold_By_Property("Request", "TopLevelResource")
| invoke Graph_Render_View()What's happening:
- Pull legacy Graph GETs from the last day and lift them into a User / Request / App / Ip graph.
Extract_Node_AadGraph_Resource("TopLevelResource")parses every request node'sRequestUriand addsTopLevelResourceto its property bag, then relabels the node with that value so the fold key is what the analyst sees.Graph_Fold_By_Property("Request", "TopLevelResource")collapses request nodes sharing the same top-level resource into one folded node per resource. A user who hit/users400 times and/groups300 times now shows as two edges into two folded nodes, not 700 edges into 700 request nodes.- Render. A user whose folded-resource fanout matches the full ROADrecon shopping list is visually obvious - the node sits at the center of a complete ring of
users,groups,tenantdetails,applications,serviceprincipals,devices,directoryroles,roledefinitions,contacts,oauth2permissiongrants,authorizationpolicy.
Pure IRQL: one selector, one Lift_To_Graph, one Extract_Node_*, one fold, one render. The same primitive that powered the tabular summarize powers the graph view here - exactly the parity the existing Extract_Node_* surface in the parent gist provides for email and network telemetry.
OAuthSentry is a defender-oriented inventory of OAuth Application IDs across identity platforms. Every app is bucketed into one of three categories: compliance (legitimate first-party / vetted), risky (legitimate apps repeatedly seen in attacker tradecraft - mailbox sync clients, AADInternals/EvilProxy abuse), or malicious (consent-phishing, AiTM lures, homoglyph impersonations, threat-actor redirect apps). The site exposes a static JSON API hosted on GitHub Pages with Access-Control-Allow-Origin: *, no auth, no rate limit beyond GitHub's defaults.
This is a perfect fit for the same single-GET pattern IRQL already uses for CISA KEV: pull the bulk lookup file once, materialize, and join. No per-row fanout.
The catalog is small (a few thousand rows of well-known AppIds) and activity-log tables are the large side. That's the textbook shape for lookup rather than join kind=leftouter - lookup is semantically a left-outer join optimized for the "small right side, big left side" case, automatically drops the redundant join-key column from the right, and reads more clearly as "enrich the left with whatever the right knows." No project-away dance afterward.
| Function | Source | Key | Shape |
|---|---|---|---|
Get_OAuthSentry_Catalog |
oauthsentry.github.io/feeds/api/v1/lookup_by_appid.json |
AppId |
Static JSON, no auth |
Enrich_AppId_OAuthSentry(T) |
Same | AppId |
lookup enrichment |
Allowlist the domain first:
.alter cluster policy callout @'[
{
"CalloutType": "webapi",
"CalloutUriRegex": "oauthsentry\\.github\\.io/.*",
"CanCall": true
}
]'.create-or-alter function with (folder="IRQL", docstring="Returns the current OAuthSentry catalog as a flat table keyed by AppId. Each app is classified as compliance, risky, or malicious.")
Get_OAuthSentry_Catalog() {
let url = 'https://oauthsentry.github.io/feeds/api/v1/lookup_by_appid.json';
evaluate http_request(url)
| mv-expand entry = bag_keys(ResponseBody)
| extend AppIdKey = tolower(tostring(entry))
| extend record = ResponseBody[tostring(entry)]
| project
AppIdKey,
OAuthSentryAppName = tostring(record.appname),
OAuthSentryService = tostring(record.service),
OAuthSentryCategory = tostring(record.category),
OAuthSentrySeverity = tostring(record.severity),
OAuthSentryComment = tostring(record.comment),
OAuthSentryReferences = record.references,
OAuthSentrySlug = tostring(record.slug)
}
.create-or-alter function with (folder="IRQL", docstring="Enriches a table of AppIds with the OAuthSentry classification (compliance / risky / malicious). Uses lookup since the catalog is small (~thousands of rows) relative to activity logs.")
Enrich_AppId_OAuthSentry(T:(AppId:string)) {
T
| where isnotempty(AppId)
| extend AppIdKey = tolower(AppId)
| lookup kind=leftouter (Get_OAuthSentry_Catalog()) on AppIdKey
| project-away AppIdKey
}A few things worth flagging about this shape:
- The catalog's join key is computed lowercase up front (
AppIdKey) rather than normalizing both sides at join time. OAuthSentry keys its JSON in lowercase; Entra emits AppIds in mixed case depending on which API surfaced them. Normalizing once on the small side keeps the lookup itself simple. lookupdrops the right-side join key automatically - no_AppIdLower1ghost column to clean up the wayjoinwould leave.project-away AppIdKeyremoves the synthetic lowercase column from the left side after the lookup completes, so callers seeAppId(the original, case-preserved) plus the OAuthSentry fields.lookupdoes require the right side to be small enough to broadcast. For the OAuthSentry catalog that's fine; if you ever swap in a much larger enrichment table (a full SPN inventory, say), revisit and usejoinwith a hint.
For high-volume use, materialize the catalog once per session rather than re-fetching:
let sentry = materialize(Get_OAuthSentry_Catalog());
Get_Event_AadGraphActivity
| extend AppIdKey = tolower(AppId)
| lookup kind=leftouter sentry on AppIdKey
| where OAuthSentryCategory in ("risky", "malicious")
| project EnvTime, ClientIp, AppId, OAuthSentryAppName, OAuthSentryCategory, OAuthSentrySeverity, RequestUriThe function catalogs above show each primitive in isolation. Real hunts compose them. The examples below are end-to-end IRQL queries - each one starts from a single hunting question and walks it to a finished result using only IRQL primitives.
Question: Did anything that looks like ROADrecon hit my tenant in the last day?
The Invictus writeup gives two detections - a User-Agent filter (Python/aiohttp) and a behavioral one (a single AppId or UserId hitting the full set of legacy top-level resources in a tight window). The behavioral one is more robust because the User-Agent flag is a one-line change. The whole detection is the IRQL legacy-Graph selector plus the resource extractor plus a summarize:
Get_Event_AadGraphActivity
| where EnvTime > ago(1d)
| where RequestMethod == "GET"
| invoke Extract_AadGraph_Resource()
| summarize
TopLevelResources = make_set(TopLevelResource),
AppIds = make_set(AppId),
ClientIps = make_set(ClientIp),
UserAgents = make_set(UserAgent),
RequestCount = count(),
StartTime = min(EnvTime),
EndTime = max(EnvTime)
by UserId, bin(EnvTime, 5m)
| where TopLevelResources has_all (
"users", "tenantdetails", "groups", "applications",
"serviceprincipals", "devices", "directoryroles",
"roledefinitions", "contacts", "oauth2permissiongrants",
"authorizationpolicy")
| order by RequestCount descGet_Event_AadGraphActivity hides the table location and the CallerIpAddress → ClientIp rename. Extract_AadGraph_Resource does the parse_url path parsing in one named step. The has_all clause is the ROADrecon shopping list straight from Dirk-jan's source. Each row in the output is a five-minute window where one user authenticated and a tool walked the full directory through the legacy API.
Question: Are any of the AppIds making AAD or MS Graph calls in my tenant classified as risky or malicious by OAuthSentry?
union
(Get_Event_AadGraphActivity | extend GraphApi = "AAD Graph (legacy)"),
(Get_Event_MicrosoftGraphActivity | extend GraphApi = "Microsoft Graph")
| where EnvTime > ago(7d)
| invoke Enrich_AppId_OAuthSentry()
| where OAuthSentryCategory in ("risky", "malicious")
| summarize
Hits = count(),
DistinctIPs = dcount(ClientIp),
DistinctUsers = dcount(UserId),
UserAgents = make_set(UserAgent, 10),
APIs = make_set(GraphApi),
FirstSeen = min(EnvTime),
LastSeen = max(EnvTime)
by AppId, OAuthSentryAppName, OAuthSentryCategory, OAuthSentrySeverity, OAuthSentryComment
| order by OAuthSentryCategory asc, Hits descThe union of the two selectors is what lets the same triage cover both Graph APIs in one pass - critical because a consent-phishing app will often touch both. Enrich_AppId_OAuthSentry attaches the OAuthSentry classification via lookup (the catalog is small, the activity table is large - the textbook shape) and handles the lowercase-AppId quirk internally. Filtering on OAuthSentryCategory in ("risky", "malicious") ranks the results by what the broader defender community already considers worth investigating.
Question: Are any apps or users dereferencing specific directory objects (specific user GUIDs, specific group GUIDs) by ID through the legacy Graph - the pattern that shows up when an attacker has a target list rather than just walking everything?
Bulk listing (/users, /groups) and targeted dereference (/users/{guid}) look the same in RequestUri substring matches but mean very different things. Targeted dereference is what an attacker does once they know who they want to enumerate - admins, executives, named accounts. The Extract_AadGraph_TargetId extractor pulls the target GUID out of the URI; everything downstream is summarize:
Get_Event_AadGraphActivity
| where EnvTime > ago(7d)
| where RequestMethod == "GET"
| invoke Extract_AadGraph_TargetId()
| where isnotempty(TargetObjectId)
| invoke Extract_AadGraph_Resource()
| summarize
TargetCount = dcount(TargetObjectId),
Targets = make_set(TargetObjectId, 50),
Resources = make_set(TopLevelResource),
RequestCount = count(),
UserAgents = make_set(UserAgent),
ClientIps = make_set(ClientIp)
by AppId, UserId
| where TargetCount >= 10
| order by TargetCount descA high TargetCount with concentrated Resources (e.g. only users and directoryroles) is the shape of "operator was working off a list" - more interesting than a tool that walked everything. Pure IRQL: one selector, two extractors, one summarize.
These functions extend the IRQL surface published in the parent gist. The selectors target tables that exist in any Log Analytics workspace where the Entra ID diagnostic settings are configured to send AADGraphActivityLogs and MicrosoftGraphActivityLogs to that workspace - enable both checkboxes under Diagnostic Settings in the Entra portal.
The OAuthSentry enrichment requires http_request callout policy to be configured for oauthsentry.github.io. The catalog updates roughly daily via the upstream GitHub Action; for high-volume detection pipelines, ingest the bulk JSON nightly into a real Kusto table rather than fetching live - the same caching pattern that applies to CISA KEV in the parent gist.
AADGraphActivityLogsenabled via Entra ID Diagnostic Settings (Microsoft started populating this table broadly in 2026 - if you enabled it earlier and the table looks empty, that's expected; check again).MicrosoftGraphActivityLogsenabled via the same Diagnostic Settings (live since late 2023).- For pivoting from AAD Graph activity to the originating sign-in (via
Extract_SignInActivityId_UnpaddedandSigninLogs.UniqueTokenIdentifier), or for any session-based correlation: theSigninLogstable also routed to the same workspace. - For the
Extract_Node_*graph-lifted variants: the companion graph functions gist (Lift_To_Graph,Graph_Render_View,Graph_Fold_By_Property) deployed on the same cluster. - For OAuthSentry: callout policy allowlisting
oauthsentry.github.io.
- Invictus IR, The Missing Link: AADGraphActivityLogs Finally Arrives - the analysis this extension is built on top of.
- Microsoft Learn, AADGraphActivityLogs table reference.
- Microsoft Learn, MicrosoftGraphActivityLogs.
- ROADtools by Dirk-jan Mollema.
- AADInternals by Dr. Nestori Syynimaa.
- OAuthSentry - defender-oriented OAuth Application ID intelligence. Surfaced in: https://x.com/mthcht2/status/2049558917001155027