{
  "$schema": "./column-relations.schema.json",
  "version": 1,
  "relations": [
    {
      "id": "entra-signin-graph-token",
      "title": "Connect an Entra sign-in to GraphAPIAuditEvents",
      "description": "Match the Entra sign-in token ID with UniqueTokenIdentifier in the Defender XDR GraphAPIAuditEvents table.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "GraphAPIAuditEvents",
        "column": "UniqueTokenIdentifier",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft Graph",
        "Token correlation"
      ],
      "guidance": "Use a relevant time window and retain context from both tables. A shared token identifier may match multiple events; this mapping does not imply a one-to-one relationship.",
      "otherNotes": [
        {
          "title": "Use the token ID to trace a sign-in session",
          "text": "GraphAPIAuditEvents.UniqueTokenIdentifier can be matched to the unique token identifier on an Entra sign-in record. In Defender XDR, this relationship is represented by EntraIdSignInEvents.UniqueTokenId. The matching sign-in can give you session context for investigating other activity. If you also query Sentinel sign-in tables, include interactive, non-interactive, service principal, and managed identity sign-ins for broader coverage. Keep user and application identities distinct when enriching results, and treat missing sign-in matches as a lead to investigate rather than proof of malicious activity.",
          "sourceLabel": "Cloudbrothers: Graph API activity logs — Part 3",
          "sourceUrl": "https://cloudbrothers.info/detect-threats-graphapiauditevents-part-3/",
          "query": "GraphAPIAuditEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenIdentifier)\n| join kind=inner (EntraIdSignInEvents\n    | where Timestamp > ago(7d)\n    | where isnotempty(UniqueTokenId)\n    | project SignInTime=Timestamp, UniqueTokenId\n) on $left.UniqueTokenIdentifier == $right.UniqueTokenId\n| project GraphTime=Timestamp, UniqueTokenIdentifier, RequestUri, RequestMethod, AccountObjectId, SignInTime\n| order by GraphTime desc",
          "queryLanguage": "kusto"
        },
        {
          "title": "Normalize Graph requests before comparing activity",
          "text": "When looking for automated discovery, compare endpoint patterns rather than raw request URLs. Replace object IDs in the path and remove query strings so requests for different objects collapse into the same pattern. Compare volume and endpoint combinations against a baseline for the account or application. UserAgent is not available in this GraphAPIAuditEvents schema; where another Graph log source provides it, remember that it can be changed and use it only as supporting context.",
          "sourceLabel": "Cloudbrothers: Graph API activity logs — Part 1",
          "sourceUrl": "https://cloudbrothers.info/detect-threats-microsoft-graph-logs-part-1/",
          "query": "GraphAPIAuditEvents\n| where Timestamp > ago(1d)\n| extend ActorId = coalesce(AccountObjectId, ServicePrincipalId, ApplicationId)\n| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}', @'<UUID>')\n| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\\?.*$', @'')\n| summarize RequestCount=count(), Endpoints=make_set(NormalizedRequestUri, 100), IPAddresses=make_set(IpAddress, 100) by ActorId, EntityType\n| order by RequestCount desc",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-microsoft-graph-activity-token",
      "title": "Connect an Entra sign-in to MicrosoftGraphActivityLogs",
      "description": "Compare the Entra sign-in token ID with SignInActivityId in the Sentinel MicrosoftGraphActivityLogs table.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "MicrosoftGraphActivityLogs",
        "column": "SignInActivityId",
        "timeColumn": "TimeGenerated"
      },
      "tags": [
        "Identity",
        "Microsoft Graph",
        "Token correlation",
        "Microsoft Sentinel"
      ],
      "guidance": "This is a separate Sentinel log table from GraphAPIAuditEvents. The source article maps SignInActivityId to UniqueTokenIdentifier in Sentinel sign-in data; compare actual values in your environment before relying on a direct join to EntraIdSignInEvents.UniqueTokenId. A token may have multiple activity records, and gaps in sign-in data do not by themselves indicate malicious activity.",
      "otherNotes": [
        {
          "title": "Keep user and application identities distinct",
          "text": "In MicrosoftGraphActivityLogs, the actor ID may be in UserId or ServicePrincipalId. Preserve which field supplied the value when enriching or grouping activity. For broader Sentinel sign-in coverage, the article unions interactive, non-interactive, service principal, and managed identity sign-in tables. Those Sentinel tables are separate from EntraIdSignInEvents in Defender XDR.",
          "sourceLabel": "Cloudbrothers: Graph API activity logs — Part 2",
          "sourceUrl": "https://cloudbrothers.info/detect-threats-microsoft-graph-logs-part-2/",
          "query": "MicrosoftGraphActivityLogs\n| where TimeGenerated > ago(1d)\n| extend ObjectId = iff(isempty(UserId), ServicePrincipalId, UserId)\n| extend ObjectType = iff(isempty(UserId), \"ServicePrincipalId\", \"UserId\")\n| summarize Requests=count(), Endpoints=dcount(RequestUri) by ObjectType, ObjectId\n| order by Requests desc",
          "queryLanguage": "kusto"
        },
        {
          "title": "Normalize request URLs and baseline activity",
          "text": "For reconnaissance hunts, compare normalized endpoint patterns and request volume by actor. Replace object IDs in URLs and remove query strings before comparing paths. User agents can be changed, and normal activity varies between environments, so treat tool fingerprints and unusual patterns as investigation leads and build a local baseline.",
          "sourceLabel": "Cloudbrothers: Graph API activity logs — Part 1",
          "sourceUrl": "https://cloudbrothers.info/detect-threats-microsoft-graph-logs-part-1/",
          "query": "MicrosoftGraphActivityLogs\n| where TimeGenerated > ago(1d)\n| extend ObjectId = iff(isempty(UserId), ServicePrincipalId, UserId)\n| extend ObjectType = iff(isempty(UserId), \"ServicePrincipalId\", \"UserId\")\n| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}', @'<UUID>')\n| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\\?.*$', @'')\n| summarize Requests=count(), Endpoints=make_set(NormalizedRequestUri, 100), IPAddresses=make_set(IPAddress, 100) by ObjectType, ObjectId\n| order by Requests desc",
          "queryLanguage": "kusto"
        },
        {
          "title": "Expand Graph batch requests",
          "text": "Microsoft Graph batch activity may include a record for the POST to $batch and separate records for the individual requests. The article uses OperationId to group the batch with its component requests, which can reveal the actual endpoints involved.",
          "sourceLabel": "Cloudbrothers: Graph API activity logs — Part 2",
          "sourceUrl": "https://cloudbrothers.info/detect-threats-microsoft-graph-logs-part-2/",
          "query": "let BatchRequests = MicrosoftGraphActivityLogs\n| where TimeGenerated > ago(1d)\n| where RequestMethod == \"POST\" and RequestUri endswith \"/$batch\"\n| project OperationId, BatchTime=TimeGenerated, BatchRequestUri=RequestUri;\nMicrosoftGraphActivityLogs\n| where TimeGenerated > ago(1d)\n| where RequestMethod != \"POST\"\n| join kind=inner BatchRequests on OperationId\n| project BatchTime, TimeGenerated, OperationId, BatchRequestUri, RequestUri, RequestMethod\n| order by BatchTime desc",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-aad-graph-activity-token",
      "title": "Connect an Entra sign-in to AADGraphActivityLogs",
      "description": "Compare EntraIdSignInEvents.UniqueTokenId with SignInActivityId in the separate Sentinel AADGraphActivityLogs table.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "AADGraphActivityLogs",
        "column": "SignInActivityId",
        "timeColumn": "TimeRequested"
      },
      "tags": [
        "Identity",
        "Azure AD Graph",
        "Token correlation",
        "Microsoft Sentinel"
      ],
      "guidance": "Treat this as a best-effort investigation link. SignInActivityId formats may differ from sign-in token IDs, so an exact join can miss activity. Compare identifiers and nearby request times; do not infer that unmatched records are malicious or that matching values are one-to-one.",
      "otherNotes": [
        {
          "title": "Account for request and record time differences",
          "text": "TimeRequested is when the API call occurred, while TimeGenerated is when the log record was generated. The article observed a median gap of about seven minutes and a maximum of 70 minutes in its lab. Measure the delay in your own data and use an appropriate time window when investigating related sign-ins.",
          "sourceLabel": "Cloudbrothers: Now You See Me — AADGraphActivityLogs",
          "sourceUrl": "https://cloudbrothers.info/aadgraphactivitylogs/",
          "query": "AADGraphActivityLogs\n| where TimeGenerated > ago(30d)\n| extend RequestDelay = TimeGenerated - TimeRequested\n| summarize MedianDelay=percentile(RequestDelay, 50), MaximumDelay=max(RequestDelay), Events=count()",
          "queryLanguage": "kusto"
        },
        {
          "title": "SignInActivityId formats",
          "text": "Invictus IR pointed out that SignInActivityId differs between sign-in logs and activity logs. The difference isn’t limited to the == padding: around 55% of my logs have this GUID format, and I haven’t found it in any other log source I’ve connected to Sentinel.",
          "sourceLabel": "Invictus IR: The missing link — AADGraphActivityLogs finally arrives",
          "sourceUrl": "https://www.invictus-ir.com/news/the-missing-link-aadgraphactivitylogs-finally-arrives#:~:text=Pivoting%20to%20the%20Sign%2Din%20Event",
          "query": "AADGraphActivityLogs\n| where TimeGenerated > ago(90d)\n| summarize by SignInActivityId\n| summarize\n    Padded=countif(SignInActivityId endswith \"==\"),\n    GUID=countif(SignInActivityId matches regex @\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\"),\n    TotalUnique=dcount(SignInActivityId)",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "alert-info-to-evidence",
      "title": "Connect an alert to its evidence",
      "description": "Use the shared alert ID to view the evidence records associated with an alert.",
      "from": {
        "table": "AlertInfo",
        "column": "AlertId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "AlertEvidence",
        "column": "AlertId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Alerts",
        "Evidence"
      ],
      "guidance": "An alert can have multiple evidence records, so expect one-to-many results. Use the evidence role and entity type to understand how each item relates to the alert."
    },
    {
      "id": "email-to-attachment-info",
      "title": "Find attachments from an email",
      "description": "Match an email to its attachment records using the shared Microsoft 365 NetworkMessageId.",
      "from": {
        "table": "EmailEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "EmailAttachmentInfo",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Email",
        "Message correlation",
        "Attachments"
      ],
      "guidance": "An email can have multiple attachments, so expect one-to-many results. Use attachment names, hashes, and timestamps to review each file."
    },
    {
      "id": "email-to-url-info",
      "title": "Find URLs contained in an email",
      "description": "Match an email to the URL records extracted from its content by NetworkMessageId.",
      "from": {
        "table": "EmailEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "EmailUrlInfo",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Email",
        "Message correlation",
        "URLs"
      ],
      "guidance": "An email can contain multiple URLs. Review each URL and its domain alongside the email verdict and recipient context."
    },
    {
      "id": "email-to-post-delivery-events",
      "title": "Review post-delivery actions for an email",
      "description": "Connect an email to actions recorded after delivery using NetworkMessageId.",
      "from": {
        "table": "EmailEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "EmailPostDeliveryEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Email",
        "Message correlation",
        "Remediation"
      ],
      "guidance": "A message may have multiple post-delivery action records. Compare action, result, and trigger fields to understand what happened and when."
    },
    {
      "id": "email-to-url-click-events",
      "title": "Trace clicks back to an email",
      "description": "Match a clicked URL event to the originating email through NetworkMessageId.",
      "from": {
        "table": "EmailEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "UrlClickEvents",
        "column": "NetworkMessageId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Email",
        "Message correlation",
        "URL clicks"
      ],
      "guidance": "A message can have multiple click records, including clicks by different recipients or at different times. Use the click timestamp, account, and URL to narrow the investigation."
    },
    {
      "id": "device-info-to-process-events",
      "title": "Find process activity for a device",
      "description": "Connect the device inventory record to process events using the Defender device ID.",
      "from": {
        "table": "DeviceInfo",
        "column": "DeviceId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "DeviceProcessEvents",
        "column": "DeviceId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Device",
        "Process activity"
      ],
      "guidance": "A device can have many process events. Set a useful time range on both tables and retain the device name and event timestamp when reviewing results."
    },
    {
      "id": "device-info-to-network-events",
      "title": "Find network activity for a device",
      "description": "Connect the device inventory record to network events using the Defender device ID.",
      "from": {
        "table": "DeviceInfo",
        "column": "DeviceId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "DeviceNetworkEvents",
        "column": "DeviceId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Device",
        "Network activity"
      ],
      "guidance": "A device can have many network events. Set a useful time range on both tables and review remote addresses, ports, initiating process details, and event timestamps."
    },
    {
      "id": "identity-info-to-logon-events",
      "title": "Add identity details to logon activity",
      "description": "Match identity profile records to logon events with the Microsoft Entra account object ID.",
      "from": {
        "table": "IdentityInfo",
        "column": "AccountObjectId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "IdentityLogonEvents",
        "column": "AccountObjectId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Account correlation",
        "Logon activity"
      ],
      "guidance": "An account can have many logon events, and IdentityInfo can contain multiple profile snapshots. For enrichment, select the newest IdentityInfo record per AccountObjectId and keep the logon event time for investigation."
    },
    {
      "id": "entra-signin-microsoft-graph-session",
      "title": "Follow an Entra session into Microsoft Graph activity",
      "description": "Match the authentication session ID in Entra sign-ins with SessionId in MicrosoftGraphActivityLogs.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "SessionId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "MicrosoftGraphActivityLogs",
        "column": "SessionId",
        "timeColumn": "TimeGenerated"
      },
      "tags": [
        "Identity",
        "Microsoft Graph",
        "Microsoft Sentinel",
        "Session correlation"
      ],
      "guidance": "A session can cover multiple tokens and many Graph requests. Keep account and time context when reviewing matches. The selected lookback must include the originating sign-in as well as the later activity.",
      "otherNotes": [
        {
          "title": "Documented identifier fields",
          "text": "Microsoft documents SessionId as the unique authentication session identifier. Entra session identifiers link authentication artifacts originating from the same root authentication. MicrosoftGraphActivityLogs is a Sentinel table and is outside the saved XDR schema snapshot.",
          "sourceLabel": "Microsoft Learn: MicrosoftGraphActivityLogs columns",
          "sourceUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/microsoftgraphactivitylogs",
          "query": "EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId)\n| join kind=inner (MicrosoftGraphActivityLogs\n    | where TimeGenerated > ago(7d)\n    | where isnotempty(SessionId)\n) on SessionId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-microsoft-graph-unique-token",
      "title": "Match the explicit token ID in Microsoft Graph activity",
      "description": "Match EntraIdSignInEvents.UniqueTokenId with the explicit UniqueTokenId field documented for MicrosoftGraphActivityLogs.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "MicrosoftGraphActivityLogs",
        "column": "UniqueTokenId",
        "timeColumn": "TimeGenerated"
      },
      "tags": [
        "Identity",
        "Microsoft Graph",
        "Microsoft Sentinel",
        "Token correlation"
      ],
      "guidance": "Compare token IDs exactly and preserve case. One token can appear in multiple request records. This entry uses the explicit UniqueTokenId column; the separate SignInActivityId mapping remains available. Check that this field is populated in your workspace.",
      "otherNotes": [
        {
          "title": "Documented identifier fields",
          "text": "The MicrosoftGraphActivityLogs table reference describes UniqueTokenId as the unique token identifier used for the audited API call. The saved Entra schema describes UniqueTokenId as the token identifier passed during sign-in. MicrosoftGraphActivityLogs is a Sentinel table and is outside the saved XDR schema snapshot.",
          "sourceLabel": "Microsoft Learn: MicrosoftGraphActivityLogs columns",
          "sourceUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/microsoftgraphactivitylogs",
          "query": "EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId)\n| join kind=inner (MicrosoftGraphActivityLogs\n    | where TimeGenerated > ago(7d)\n    | where isnotempty(UniqueTokenId)\n) on UniqueTokenId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-exchange-session",
      "title": "Follow an Entra session into Exchange mailbox activity",
      "description": "Match EntraIdSignInEvents.SessionId with the SessionId property inside CloudAppEvents.RawEventData for supported Exchange audit events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "SessionId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "SessionId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Exchange Online",
        "Session correlation",
        "Nested JSON"
      ],
      "guidance": "The source example extracts RawEventData.SessionId from MailItemsAccessed events. Availability and meaning depend on the audit workload; this mapping does not cover every CloudAppEvents record. Retain account and time context, exclude empty identifiers, and allow for multiple events per session. The example below also matches AccountObjectId.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where ActionType == \"MailItemsAccessed\"\n| extend SessionId = tostring(RawEventData.SessionId)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Mailbox activity example",
          "text": "Thomas’s hunting query extracts SessionId from RawEventData while examining MailItemsAccessed events. It demonstrates the nested property; it does not itself join that property to Entra sign-ins.",
          "sourceLabel": "Cloud-Architekt: Hunt a multi-stage incident",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureAD-Attack-Defense/blob/main/queries/MDA-Hunt-Multi-Stage-Incident.kql",
          "query": "CloudAppEvents\n| where Timestamp > ago(7d)\n| where ActionType == \"MailItemsAccessed\"\n| extend ExchangeSessionId = tostring(RawEventData.SessionId)\n| where isnotempty(ExchangeSessionId)\n| project Timestamp, AccountObjectId, ActionType, ExchangeSessionId",
          "queryLanguage": "kusto"
        },
        {
          "title": "Linking Exchange activity to authentication",
          "text": "Microsoft documents session identifiers in Exchange audit logs as linkable to Entra sign-ins. Some aggregated entries and background activity omit these identifiers.",
          "sourceLabel": "Microsoft Learn: Linkable identifiers in Exchange Online logs",
          "sourceUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-authentication-track-linkable-identifiers#linkable-identifiers-in-microsoft-exchange-online-logs",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where ActionType == \"MailItemsAccessed\"\n| extend SessionId = tostring(RawEventData.SessionId)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-m365-session",
      "title": "Follow an Entra session into Microsoft 365 activity",
      "description": "Match EntraIdSignInEvents.SessionId with CloudAppEvents.RawEventData.AppAccessContext.AADSessionId in supported Microsoft 365 events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "SessionId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "AppAccessContext.AADSessionId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft 365",
        "Session correlation",
        "Nested JSON"
      ],
      "guidance": "A session can contain several tokens and many activity records. This nested field is only available in some Microsoft 365 events. The example excludes empty identifiers and matches AccountObjectId as well. Adjust the lookback to include the originating sign-in and the later activity.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application != \"Microsoft Azure\"\n| where tostring(RawEventData.Workload) in (\"Exchange\", \"SharePoint\", \"OneDrive\", \"MicrosoftTeams\")\n| extend SessionId = tostring(RawEventData.AppAccessContext.AADSessionId)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Token hunting example",
          "text": "Thomas’s query extracts this field from Microsoft 365 activity and correlates activity with Entra sign-in logs by token ID. It also extracts the session ID for session filtering. This example applies that session identifier to EntraIdSignInEvents.",
          "sourceLabel": "Cloud-Architekt: Microsoft cloud activity",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/MicrosoftCloudActivity.func",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application != \"Microsoft Azure\"\n| where tostring(RawEventData.Workload) in (\"Exchange\", \"SharePoint\", \"OneDrive\", \"MicrosoftTeams\")\n| extend SessionId = tostring(RawEventData.AppAccessContext.AADSessionId)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-m365-token",
      "title": "Follow an Entra token into Microsoft 365 activity",
      "description": "Match EntraIdSignInEvents.UniqueTokenId with CloudAppEvents.RawEventData.AppAccessContext.UniqueTokenId in supported Microsoft 365 events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "AppAccessContext.UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft 365",
        "Token correlation",
        "Nested JSON"
      ],
      "guidance": "Compare token IDs exactly and preserve case. One token can appear in several activity records. This nested field is only available in some Microsoft 365 events. The example excludes empty identifiers and matches AccountObjectId as well. Adjust the lookback to include the originating sign-in and the later activity.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application != \"Microsoft Azure\"\n| where tostring(RawEventData.Workload) in (\"Exchange\", \"SharePoint\", \"OneDrive\", \"MicrosoftTeams\")\n| extend UniqueTokenId = tostring(RawEventData.AppAccessContext.UniqueTokenId)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Token hunting example",
          "text": "Thomas’s query extracts this field from Microsoft 365 activity and correlates activity with Entra sign-in logs by token ID. This example uses the corresponding UniqueTokenId field in EntraIdSignInEvents.",
          "sourceLabel": "Cloud-Architekt: Microsoft cloud activity",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/MicrosoftCloudActivity.func",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application != \"Microsoft Azure\"\n| where tostring(RawEventData.Workload) in (\"Exchange\", \"SharePoint\", \"OneDrive\", \"MicrosoftTeams\")\n| extend UniqueTokenId = tostring(RawEventData.AppAccessContext.UniqueTokenId)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-azure-session",
      "title": "Follow an Entra session into Azure activity",
      "description": "Match EntraIdSignInEvents.SessionId with CloudAppEvents.RawEventData.claims.sid in supported Microsoft Azure events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "SessionId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "claims.sid",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft Azure",
        "Session correlation",
        "Nested JSON"
      ],
      "guidance": "A session can contain several tokens and many activity records. This nested field is only available in some Microsoft Azure events. The example excludes empty identifiers and matches AccountObjectId as well. Adjust the lookback to include the originating sign-in and the later activity. The claims value can be a JSON object or a JSON-encoded string; the example parses both forms.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend SessionId = tostring(parse_json(tostring(RawEventData.claims)).sid)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Token hunting example",
          "text": "Thomas’s query extracts this field from Microsoft Azure activity and correlates activity with Entra sign-in logs by token ID. It also extracts the session ID for session filtering. This example applies that session identifier to EntraIdSignInEvents.",
          "sourceLabel": "Cloud-Architekt: Microsoft cloud activity",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/MicrosoftCloudActivity.func",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend SessionId = tostring(parse_json(tostring(RawEventData.claims)).sid)\n| where isnotempty(SessionId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on SessionId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-azure-claims-token",
      "title": "Find an Entra token in Azure activity claims",
      "description": "Match EntraIdSignInEvents.UniqueTokenId with CloudAppEvents.RawEventData.claims.uti in supported Microsoft Azure events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "claims.uti",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft Azure",
        "Token correlation",
        "Nested JSON"
      ],
      "guidance": "Compare token IDs exactly and preserve case. One token can appear in several activity records. This nested field is only available in some Microsoft Azure events. The example excludes empty identifiers and matches AccountObjectId as well. Adjust the lookback to include the originating sign-in and the later activity. The claims value can be a JSON object or a JSON-encoded string; the example parses both forms.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend UniqueTokenId = tostring(parse_json(tostring(RawEventData.claims)).uti)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Token hunting example",
          "text": "Thomas’s query extracts this field from Microsoft Azure activity and correlates activity with Entra sign-in logs by token ID. This example uses the corresponding UniqueTokenId field in EntraIdSignInEvents.",
          "sourceLabel": "Cloud-Architekt: Microsoft cloud activity",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/MicrosoftCloudActivity.func",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend UniqueTokenId = tostring(parse_json(tostring(RawEventData.claims)).uti)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "entra-signin-cloudapp-azure-token",
      "title": "Find an Entra token in the Azure event token field",
      "description": "Match EntraIdSignInEvents.UniqueTokenId with CloudAppEvents.RawEventData.uniqueTokenId in supported Microsoft Azure events.",
      "from": {
        "table": "EntraIdSignInEvents",
        "column": "UniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "CloudAppEvents",
        "column": "RawEventData",
        "path": "uniqueTokenId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Identity",
        "Microsoft Azure",
        "Token correlation",
        "Nested JSON"
      ],
      "guidance": "Compare token IDs exactly and preserve case. One token can appear in several activity records. This nested field is only available in some Microsoft Azure events. The example excludes empty identifiers and matches AccountObjectId as well. Adjust the lookback to include the originating sign-in and the later activity. The property name starts with a lowercase u. Other Azure events expose the token in claims.uti, which has its own mapping.",
      "kql": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend UniqueTokenId = tostring(RawEventData.uniqueTokenId)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
      "otherNotes": [
        {
          "title": "Token hunting example",
          "text": "Thomas’s query extracts this field from Microsoft Azure activity and correlates activity with Entra sign-in logs by token ID. This example uses the corresponding UniqueTokenId field in EntraIdSignInEvents.",
          "sourceLabel": "Cloud-Architekt: Sensitive privileged endpoint activity",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/SensitivePrivilegedEndpointActivity.func",
          "query": "let SignIns = EntraIdSignInEvents\n| where Timestamp > ago(7d)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId);\nCloudAppEvents\n| where Timestamp > ago(7d)\n| where Application == \"Microsoft Azure\"\n| extend UniqueTokenId = tostring(RawEventData.uniqueTokenId)\n| where isnotempty(UniqueTokenId) and isnotempty(AccountObjectId)\n| join kind=inner (SignIns) on UniqueTokenId, AccountObjectId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "behavior-info-to-entities",
      "title": "See the entities involved in a behavior",
      "description": "Connect a behavior in BehaviorInfo to its related records in BehaviorEntities using BehaviorId.",
      "from": {
        "table": "BehaviorInfo",
        "column": "BehaviorId",
        "timeColumn": "Timestamp"
      },
      "to": {
        "table": "BehaviorEntities",
        "column": "BehaviorId",
        "timeColumn": "Timestamp"
      },
      "tags": [
        "Behaviors",
        "Entities",
        "Microsoft Defender XDR"
      ],
      "guidance": "One behavior can involve several entities, so a match can return several rows. Use EntityType and EntityRole to narrow the results to the entities you need. Keep the time window wide enough to include records from both tables.",
      "kql": "BehaviorInfo\n| where Timestamp > ago(7d)\n| where isnotempty(BehaviorId)\n| join kind=inner (BehaviorEntities\n    | where Timestamp > ago(7d)\n    | where isnotempty(BehaviorId)\n) on BehaviorId",
      "otherNotes": [
        {
          "title": "Behavior enrichment example",
          "text": "Thomas’s query joins BehaviorEntities with BehaviorInfo on BehaviorId, then uses the related user, IP address, and application entities to investigate the behavior. Both schema descriptions identify BehaviorId as the unique identifier for the behavior.",
          "sourceLabel": "Cloud-Architekt: Enrich sign-ins with XDR behaviors",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/XdrBehaviorsEnrichedSignIns.func",
          "query": "BehaviorInfo\n| where Timestamp > ago(7d)\n| where isnotempty(BehaviorId)\n| join kind=inner (BehaviorEntities\n    | where Timestamp > ago(7d)\n    | where isnotempty(BehaviorId)\n) on BehaviorId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "exposure-graph-source-node",
      "title": "Inspect the source node of an exposure graph edge",
      "description": "Connect ExposureGraphEdges.SourceNodeId to ExposureGraphNodes.NodeId to inspect the node at the source end of an edge.",
      "from": {
        "table": "ExposureGraphEdges",
        "column": "SourceNodeId"
      },
      "to": {
        "table": "ExposureGraphNodes",
        "column": "NodeId"
      },
      "tags": [
        "Exposure Management",
        "Graph",
        "Microsoft Defender XDR"
      ],
      "guidance": "An edge runs from its source node to its target node. This mapping resolves the source end; the other end has its own mapping. A node can participate in many edges. Filter by EdgeLabel or node type to focus on the relationships you need. These tables have no Timestamp column in the saved schema, so the example does not apply a time filter.",
      "kql": "ExposureGraphEdges\n| where isnotempty(SourceNodeId)\n| join kind=inner (ExposureGraphNodes\n    | where isnotempty(NodeId)\n) on $left.SourceNodeId == $right.NodeId",
      "otherNotes": [
        {
          "title": "Exposed token hunting example",
          "text": "Thomas’s exposed-token query joins graph edges to nodes through SourceNodeId and NodeId. The saved schema explicitly describes SourceNodeId as the edge’s source node ID and NodeId as the unique node identifier.",
          "sourceLabel": "Cloud-Architekt: Overview of exposed token artifacts",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/ExposedTokens-OverviewOfTokenArtifcats.kql",
          "query": "ExposureGraphEdges\n| where isnotempty(SourceNodeId)\n| join kind=inner (ExposureGraphNodes\n    | where isnotempty(NodeId)\n) on $left.SourceNodeId == $right.NodeId",
          "queryLanguage": "kusto"
        }
      ]
    },
    {
      "id": "exposure-graph-target-node",
      "title": "Inspect the target node of an exposure graph edge",
      "description": "Connect ExposureGraphEdges.TargetNodeId to ExposureGraphNodes.NodeId to inspect the node at the target end of an edge.",
      "from": {
        "table": "ExposureGraphEdges",
        "column": "TargetNodeId"
      },
      "to": {
        "table": "ExposureGraphNodes",
        "column": "NodeId"
      },
      "tags": [
        "Exposure Management",
        "Graph",
        "Microsoft Defender XDR"
      ],
      "guidance": "An edge runs from its source node to its target node. This mapping resolves the target end; the other end has its own mapping. A node can participate in many edges. Filter by EdgeLabel or node type to focus on the relationships you need. These tables have no Timestamp column in the saved schema, so the example does not apply a time filter.",
      "kql": "ExposureGraphEdges\n| where isnotempty(TargetNodeId)\n| join kind=inner (ExposureGraphNodes\n    | where isnotempty(NodeId)\n) on $left.TargetNodeId == $right.NodeId",
      "otherNotes": [
        {
          "title": "Exposed token hunting example",
          "text": "Thomas’s exposed-token query joins graph edges to nodes through TargetNodeId and NodeId. The saved schema explicitly describes TargetNodeId as the edge’s target node ID and NodeId as the unique node identifier.",
          "sourceLabel": "Cloud-Architekt: Overview of exposed token artifacts",
          "sourceUrl": "https://github.com/Cloud-Architekt/AzureSentinel/blob/main/Hunting%20Queries/EID-TokenHunting/ExposedTokens-OverviewOfTokenArtifcats.kql",
          "query": "ExposureGraphEdges\n| where isnotempty(TargetNodeId)\n| join kind=inner (ExposureGraphNodes\n    | where isnotempty(NodeId)\n) on $left.TargetNodeId == $right.NodeId",
          "queryLanguage": "kusto"
        }
      ]
    }
  ]
}
