Add analytic rule: End-user consent to app with mailbox + offline_access scopes - #15047
Add analytic rule: End-user consent to app with mailbox + offline_access scopes#15047eduardarbona (earbona23) wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Pull request overview
Adds a new scheduled Microsoft Sentinel analytic rule to detect risky end-user OAuth consent events granting offline_access plus high‑risk delegated mailbox scopes (T1528), aiming to improve fidelity vs. existing offline-access consent detections.
Changes:
- Introduces a new YAML analytic rule backed by an AuditLogs KQL query.
- Filters for end-user (non-
AllPrincipals) consent and surfaces app/user/IP/user-agent context. - Adds entity mappings for Account, CloudApplication, and IP enrichment.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| | where ConsentFull has "offline_access" | ||
| | where ConsentFull has_any ("Mail.Read", "Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite") | ||
| | parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope "]" * |
| | extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id) | ||
| | extend GrantIpAddress = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress)) | ||
| | extend GrantUserAgent = tostring(iff(AdditionalDetails[0].key =~ "User-Agent", AdditionalDetails[0].value, "")) | ||
| | extend Name = tostring(split(GrantInitiatedByUserPrincipalName, '@', 0)[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName, '@', 1)[0]) |
| description: | | ||
| 'Identifies an illicit consent grant where a non-admin user consents to an | ||
| application requesting high-risk delegated mailbox permissions | ||
| (Mail.Read, Mail.ReadWrite, Mail.Send or MailboxSettings.ReadWrite) together | ||
| with offline_access. offline_access returns a long-lived refresh token, so a | ||
| single successful end-user consent gives an attacker-controlled application | ||
| durable, silent read/send access to the victim mailbox without re-prompting | ||
| for MFA. This is a common precursor to business email compromise (BEC), | ||
| mailbox exfiltration and internal phishing. This rule complements | ||
| "Suspicious application consent for offline access": it drops the | ||
| known-application allowlist join and instead pins the high-fidelity scope | ||
| combination, and it additionally covers mailbox write/send scopes | ||
| (Mail.ReadWrite, Mail.Send, MailboxSettings.ReadWrite), not only read. | ||
| For AuditLogs schema see https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.' |
| | extend AppDisplayName = tostring(TargetResource.displayName), | ||
| AppClientId = tolower(tostring(TargetResource.id)), | ||
| ModifiedProperties = TargetResource.modifiedProperties |
| | extend GrantUserAgent = tostring(iff(AdditionalDetails[0].key =~ "User-Agent", AdditionalDetails[0].value, "")) | ||
| | extend Name = tostring(split(GrantInitiatedByUserPrincipalName, '@', 0)[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName, '@', 1)[0]) |
| - entityType: Account | ||
| fieldMappings: |
|
This rule detects end-user consent to an app requesting mailbox scopes together with offline_access, the pattern behind illicit consent grants that hand an attacker a long lived refresh token without a password or an MFA prompt (MITRE T1528). Ready for review, happy to adjust the query or metadata to fit the repo conventions. |
…ePrincipalId naming, YAML/split cleanup
|
Thanks for the review — addressed the feedback:
Left the User-Agent |
|
Hi eduardarbona (@earbona23) , Please resolve below validation error, |
…3-field schema max DetectionTemplateSchemaValidation caps each entityMapping at 3 fieldMappings; the consolidated Account entity had 4. FullName is redundant (Name+UPNSuffix reconstruct the UPN, also projected as GrantInitiatedByUserPrincipalName). Keeps Name/UPNSuffix/AadUserId.
|
Hi v-rusraut, thanks for the review. Validation error fixed — the consolidated Copilot comments — all addressed: I will follow up with the V3-packaged solution and a screenshot of the query running. |
|
v-rusraut — a quick question on the packaging step before I push a large diff. This PR adds a single analytic rule to the existing Microsoft Entra ID solution. When I ran the V3 tool locally, it regenerated the entire solution For a single-rule addition like this, would you prefer that I (a) commit the full V3-regenerated package, or (b) is the analytic-rule YAML sufficient here, with the solution package regenerated on the pipeline/maintainer side? I want to avoid introducing an unintended large package diff. Happy to proceed either way — thanks! |
|
v-rusraut — an update on the V3 packaging, with the specifics I ran into. I added the rule to
I'd rather not commit a package that takes the solution from 1 to 2 ARM-TTK failures and diverges this much from canonical. Could you point me to the tool version/branch the current Entra ID package was built with, or regenerate it on your side at merge? The rule content is ready either way — the entityMapping 3-field validation error is fixed, all Copilot comments are resolved, and the query follows the On the screenshot: the rule fires on |
…ytic rule Register the new rule in Solution_AAD.json and add it to the Microsoft Entra ID V3 package: analyticRuleObject74 variable, its contentTemplate resource, and the contentPackages dependency in mainTemplate.json; the analytics element in createUiDefinition.json; the 3.3.17 package zip; and the ReleaseNotes entry. Solution version bumped 3.3.16 -> 3.3.17. Existing rules are unchanged apart from the version-string bump, and ARM-TTK stays at the package baseline.
|
Update — the rule is now packaged into the solution, and I've confirmed the detection against real data. Packaging (pushed). Instead of committing a full tool regeneration (which rewrote all 73 existing rules with key-order churn and empty
The 73 existing rules are untouched apart from the Validation against real data. I ran the rule in a production tenant against live |
|
Hi eduardarbona (@earbona23) , |

Scheduled analytic rule over AuditLogs (T1528). Complements 'Suspicious application consent for offline access': drops the known-app allowlist, pins the scope combination, and adds mailbox write/send scopes. KQL syntax validated with the Kusto.Language parser (0 diagnostics).