Skip to content

Instantly share code, notes, and snippets.

@ddamenova
Created May 18, 2026 17:43
Show Gist options
  • Select an option

  • Save ddamenova/3916ff047572e26a930ee33164d2e8d8 to your computer and use it in GitHub Desktop.

Select an option

Save ddamenova/3916ff047572e26a930ee33164d2e8d8 to your computer and use it in GitHub Desktop.
New IRQL functions in the relational and graph world.

IRQL for AADGraphActivityLogs

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.

Security Background

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.

Schema notes

The full schema is documented here. Field-level details that motivate the primitives below:

  • SignInActivityId is the bridge to SigninLogs.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.
  • SessionId correlates a single user session across SigninLogs, AADGraphActivityLogs, MicrosoftGraphActivityLogs, and the Unified Audit Log. It's the most useful tenant-wide pivot key in the new table.
  • CallerIpAddress is a real IP for delegated (user) flows, but a Microsoft-owned IP for service-principal flows. IP-based hunting has to account for that.
  • ActorType is the fastest hot-path filter (User vs Application) - the first cut in nearly every hunt.
  • RequestUri carries the actual legacy endpoint hit (/users, /groups, /devices, /serviceprincipals, ...) and is the foundation for ROADrecon-style "shopping list" detections.
  • AppId identifies the OAuth application making the request - and is the key the OAuthSentry enrichment in section 4 joins against.

Everything below leans on these.


1. Selectors - Get_*

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 (TimeGeneratedEnvTime, CallerIpAddress / IPAddressClientIp, AADTenantIdTenantId) 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 desc

2. Extractors - Extract_*

Three 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 desc

2a. Extractors - Graph-Lifted Variants - Extract_Node_*

The 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_*, not Enrich_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 (RequestUri or SignInActivityId). Untargeted rows - edges, and nodes whose props mapping didn't include the source property - flow through unchanged via the Untouched branch. In practice that means your Lift_To_Graph mapping has to list the source column in the relevant node type's props array; otherwise there's nothing to read from.
  • bag_merge, not bag_pack. The merge preserves existing properties on the bag and only adds the new one. Replacing the bag with bag_pack would clobber everything else the node carries.
  • Display-name update is opt-in. The default newDisplayName="" leaves nodeDisplayName alone. 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.

Example: ROADrecon shopping-list detection, lifted into a graph

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:

  1. Pull legacy Graph GETs from the last day and lift them into a User / Request / App / Ip graph.
  2. Extract_Node_AadGraph_Resource("TopLevelResource") parses every request node's RequestUri and adds TopLevelResource to its property bag, then relabels the node with that value so the fold key is what the analyst sees.
  3. 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 /users 400 times and /groups 300 times now shows as two edges into two folded nodes, not 700 edges into 700 request nodes.
  4. 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.


3. External Enrichment - OAuthSentry

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.
  • lookup drops the right-side join key automatically - no _AppIdLower1 ghost column to clean up the way join would leave.
  • project-away AppIdKey removes the synthetic lowercase column from the left side after the lookup completes, so callers see AppId (the original, case-preserved) plus the OAuthSentry fields.
  • lookup does 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 use join with 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, RequestUri

End-to-End Examples

The 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.

Example 1: ROADrecon "shopping list" detection

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 desc

Get_Event_AadGraphActivity hides the table location and the CallerIpAddressClientIp 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.

Example 2: OAuth consent-phishing triage across both Graph APIs

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 desc

The 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.

Example 3: Targeted-object enumeration

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 desc

A 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.


Availability

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.

Prerequisites

  • AADGraphActivityLogs enabled 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).
  • MicrosoftGraphActivityLogs enabled via the same Diagnostic Settings (live since late 2023).
  • For pivoting from AAD Graph activity to the originating sign-in (via Extract_SignInActivityId_Unpadded and SigninLogs.UniqueTokenIdentifier), or for any session-based correlation: the SigninLogs table 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.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment