{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "workspace": {
            "type": "String"
        }
    },
    "resources": [
        {
            "id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/guest-users-invited-to-tenant-by-new-inviters')]",
            "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/guest-users-invited-to-tenant-by-new-inviters')]",
            "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
            "kind": "Scheduled",
            "apiVersion": "2023-12-01-preview",
            "properties": {
                "displayName": "Suspicious Guest Invitation Activity",
                "description": "Detects successful external guest invitations where the combination of inviter state and invitation shape indicates possible account abuse, insider staging or unwanted external persistence, instead of alerting on every first-time inviter. Weighted signals cover the inviter being a guest account, risky sign-ins by the inviter within the last day, invitations landing on consumer mail domains, invitation bursts, a newly created inviter account, and the inviter not having invited anyone in the prior 14 days. An alert fires only when the accumulated RiskScore reaches the FireThreshold.\n\nTABLES: AuditLogs, SigninLogs, IdentityInfo\nCONNECTORS: Microsoft Entra ID (Audit Logs, Sign-in Logs); UEBA enabled for IdentityInfo\nLICENSE: Entra ID P1 for log export; P2 adds the risky sign-in signal; UEBA adds inviter directory context and the account age signal\n\nTUNING:\n1. Query period must be P14D - the 14d inviter baseline and identity lookups only resolve if the rule looks back that far.\n2. DetectionWindow must match the rule frequency (PT1H) so each invitation is scored once.\n3. BurstThreshold controls the invitation volume treated as a burst; raise it for tenants with heavy B2B onboarding.\n4. FreeMailDomains should be extended with the consumer providers your user base actually uses.\n5. FireThreshold at 5 means guest inviters and risky inviters alert alone while soft signals must stack.\n\nKNOWN FALSE POSITIVES:\n1. Recruiters and events teams inviting candidates on consumer mailboxes alert once on their first invitation, then drop into the baseline and stay silent.\n2. Onboarding waves produce a burst signal only, which stays below threshold for established inviters.\n\nINVESTIGATION STEPS:\n1. Confirm with the inviter or their manager that the invitation was intended.\n2. Review the invited address and domain; treat consumer mailboxes with no business justification as hostile until confirmed.\n3. Check the inviter sign-in history around the invitation for unfamiliar IPs, impossible travel or risk events.\n4. If the invitation is unwanted, delete the guest object and review any sharing links or group memberships already granted.\n\nBLIND SPOTS:\nInvitations initiated by applications carry no user principal and are not scored. An attacker can clear the first-time signal by seeding a harmless invitation in an earlier run window. Attacker-registered custom domains bypass the consumer mail list. Account age and inviter memory are capped at 14 days by the platform query period ceiling.\n\nAuthor: Bartosz Wysocki | https://www.itprofessor.cloud\nVersion: 1.0 | 2026-07-24",
                "severity": "Medium",
                "enabled": true,
                "query": "// =====================================================================\n// Suspicious Guest Invitation Activity\n// =====================================================================\n// Tables  : AuditLogs, SigninLogs, IdentityInfo\n// License : Entra ID P1; P2 adds the risky sign-in signal\n// Tuning  : see rule description for full tuning notes\n// Author  : Bartosz Wysocki | itprofessor.cloud\n// Version : 1.0 | 2026-07-24\n// =====================================================================\n// Tuning variables\nlet DetectionWindow = 1h;        // must match the rule frequency so each invitation is scored once\nlet BaselineWindow = 14d;        // must match the rule query period; platform cap is P14D\nlet BurstWindow = 1d;            // window for counting invitation volume per inviter\nlet BurstThreshold = 5;          // invitations within BurstWindow treated as a burst\nlet IdentityLookback = 14d;      // how far back to resolve IdentityInfo snapshots\nlet RiskLookback = 1d;           // window for risky sign-ins by the inviter; needs Entra ID P2\n// Scoring: serious signals clear FireThreshold alone; soft signals only stack\nlet FireThreshold = 5;\nlet W_GuestInviter = 5;          // the inviter is itself a guest account\nlet W_RiskyInviter = 5;          // the inviter had medium or high risk sign-ins in the last day\nlet W_FreeMailGuest = 3;         // an invited guest lands on a consumer mail domain\nlet W_InviteBurst = 3;           // invitation volume at or above BurstThreshold in BurstWindow\nlet W_NewAccount = 2;            // the inviter account was created inside the last 14 days\nlet W_NewInviter = 2;            // no invitations from this account in the prior baseline\n// Consumer mail domains that rarely belong to a business partner\nlet FreeMailDomains = dynamic([\"gmail.com\", \"googlemail.com\", \"outlook.com\", \"hotmail.com\", \"live.com\", \"yahoo.com\", \"ymail.com\", \"icloud.com\", \"me.com\", \"aol.com\", \"proton.me\", \"protonmail.com\", \"gmx.com\", \"gmx.de\", \"mail.com\", \"zoho.com\", \"yandex.com\", \"qq.com\", \"163.com\", \"wp.pl\", \"onet.pl\", \"interia.pl\", \"o2.pl\"]);\n// All successful external invitations in the query period, one row per invited guest\nlet Invites = materialize(\n    AuditLogs\n    | where TimeGenerated > ago(BaselineWindow)\n    | where OperationName =~ \"Invite external user\"\n    | where Result =~ \"success\"\n    | extend InviterUPN = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName),\n             InviterId = tostring(parse_json(tostring(InitiatedBy.user)).id),\n             InviterIP = tostring(parse_json(tostring(InitiatedBy.user)).ipAddress)\n    | where isnotempty(InviterUPN)\n    | mv-apply Target = TargetResources on (\n        where tostring(Target.type) =~ \"User\"\n        | project GuestUPN = tostring(Target.userPrincipalName), GuestId = tostring(Target.id)\n        | take 1\n        )\n    | extend GuestDomain = tolower(iff(GuestUPN has \"#EXT#\", extract(@\"_([^_]+)#EXT#\", 1, GuestUPN), tostring(split(GuestUPN, \"@\", 1)[0])))\n    | project TimeGenerated, InviterUPN, InviterId, InviterIP, GuestUPN, GuestId, GuestDomain\n);\n// Accounts that already invited guests before the current detection window\nlet KnownInviters = Invites\n    | where TimeGenerated between (ago(BaselineWindow) .. ago(DetectionWindow))\n    | distinct InviterUPN;\n// Invitation volume per inviter over the burst window\nlet BurstCounts = Invites\n    | where TimeGenerated > ago(BurstWindow)\n    | summarize InvitesLast24h = count() by InviterUPN;\n// Inviters with risky sign-ins; empty without Entra ID P2, which silences the signal\nlet RiskyInviters = SigninLogs\n    | where TimeGenerated > ago(RiskLookback)\n    | where RiskLevelDuringSignIn in~ (\"medium\", \"high\")\n    | summarize RiskySignIns = count() by UserPrincipalName;\n// Latest directory snapshot per account for inviter context\nlet InviterIdentity = IdentityInfo\n    | where TimeGenerated > ago(IdentityLookback)\n    | summarize arg_max(TimeGenerated, Department, JobTitle, AssignedRoles, UserType) by AccountUPN\n    | extend AccountCreated = column_ifexists(\"CreatedDateTime\", datetime(null))\n    | project AccountUPN, InviterDepartment = Department, InviterJobTitle = JobTitle,\n              InviterRoles = tostring(AssignedRoles), InviterType = UserType, AccountCreated;\nInvites\n| where TimeGenerated > ago(DetectionWindow)\n| extend IsFreeMail = GuestDomain in~ (FreeMailDomains)\n| extend SampleRank = toint(IsFreeMail)\n| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated),\n    InviteCount = count(),\n    FreeMailInvites = countif(IsFreeMail),\n    GuestDomains = strcat_array(make_set(GuestDomain, 20), \", \"),\n    arg_max(SampleRank, GuestUPN),\n    take_any(InviterId), take_any(InviterIP)\n    by InviterUPN\n| join kind=leftouter (InviterIdentity) on $left.InviterUPN == $right.AccountUPN\n| join kind=leftouter (RiskyInviters) on $left.InviterUPN == $right.UserPrincipalName\n| join kind=leftouter (BurstCounts) on InviterUPN\n| extend RiskySignIns = coalesce(RiskySignIns, 0), InvitesLast24h = coalesce(InvitesLast24h, 0)\n| extend IsGuestInviter = InviterType =~ \"Guest\" or InviterUPN has \"#EXT#\"\n| extend IsRiskyInviter = RiskySignIns > 0\n| extend IsBurst = InvitesLast24h >= BurstThreshold\n| extend IsNewAccount = isnotempty(AccountCreated) and AccountCreated > ago(BaselineWindow)\n| extend IsNewInviter = InviterUPN !in (KnownInviters)\n| extend RiskScore =\n    iff(IsGuestInviter, W_GuestInviter, 0)\n    + iff(IsRiskyInviter, W_RiskyInviter, 0)\n    + iff(FreeMailInvites > 0, W_FreeMailGuest, 0)\n    + iff(IsBurst, W_InviteBurst, 0)\n    + iff(IsNewAccount, W_NewAccount, 0)\n    + iff(IsNewInviter, W_NewInviter, 0)\n| where RiskScore >= FireThreshold\n| extend Risk_1 = iff(IsGuestInviter, \"Inviter is itself a guest account\", \"\")\n| extend Risk_2 = iff(IsRiskyInviter, strcat(\"Inviter had \", RiskySignIns, \" risky sign-ins in the last day\"), \"\")\n| extend Risk_3 = iff(FreeMailInvites > 0, strcat(FreeMailInvites, \" invite(s) sent to consumer mail domains\"), \"\")\n| extend Risk_4 = iff(IsBurst, strcat(InvitesLast24h, \" invitations in the last day\"), \"\")\n| extend Risk_5 = iff(IsNewAccount, \"Inviter account created inside the last 14 days\", \"\")\n| extend Risk_6 = iff(IsNewInviter, \"First guest invitation from this account in 14 days\", \"\")\n| extend RiskIndicators = trim(@\"\\s\\|\\s*$\", strcat(\n    iff(isnotempty(Risk_1), strcat(Risk_1, \" | \"), \"\"),\n    iff(isnotempty(Risk_2), strcat(Risk_2, \" | \"), \"\"),\n    iff(isnotempty(Risk_3), strcat(Risk_3, \" | \"), \"\"),\n    iff(isnotempty(Risk_4), strcat(Risk_4, \" | \"), \"\"),\n    iff(isnotempty(Risk_5), strcat(Risk_5, \" | \"), \"\"),\n    iff(isnotempty(Risk_6), strcat(Risk_6, \" | \"), \"\")))\n| extend InviterName = tostring(split(InviterUPN, \"@\", 0)[0]), InviterUPNSuffix = tostring(split(InviterUPN, \"@\", 1)[0])\n| extend GuestName = tostring(split(GuestUPN, \"@\", 0)[0]), GuestUPNSuffix = tostring(split(GuestUPN, \"@\", 1)[0])\n| project StartTime, EndTime, InviterUPN, InviterName, InviterUPNSuffix, InviterId, InviterIP,\n    InviterDepartment, InviterJobTitle, InviterRoles, InviterType,\n    GuestUPN, GuestName, GuestUPNSuffix, GuestDomains, InviteCount, FreeMailInvites, InvitesLast24h,\n    RiskScore, RiskIndicators\n| sort by RiskScore desc, EndTime desc",
                "queryFrequency": "PT1H",
                "queryPeriod": "P14D",
                "triggerOperator": "GreaterThan",
                "triggerThreshold": 0,
                "suppressionDuration": "PT1H",
                "suppressionEnabled": false,
                "startTimeUtc": null,
                "tactics": [
                    "Persistence"
                ],
                "techniques": [
                    "T1136"
                ],
                "subTechniques": [
                    "T1136.003"
                ],
                "alertRuleTemplateName": null,
                "incidentConfiguration": {
                    "createIncident": true,
                    "groupingConfiguration": {
                        "enabled": true,
                        "reopenClosedIncident": false,
                        "lookbackDuration": "PT8H",
                        "matchingMethod": "Selected",
                        "groupByEntities": [
                            "Account"
                        ],
                        "groupByAlertDetails": null,
                        "groupByCustomDetails": null
                    }
                },
                "eventGroupingSettings": {
                    "aggregationKind": "AlertPerResult"
                },
                "alertDetailsOverride": {
                    "alertDisplayNameFormat": "Suspicious guest invitation activity by {{InviterUPN}}",
                    "alertDescriptionFormat": "{{InviterUPN}} sent {{InviteCount}} guest invitation(s). Indicators: {{RiskIndicators}}"
                },
                "customDetails": {
                    "RiskScore": "RiskScore",
                    "RiskIndicators": "RiskIndicators",
                    "InviteCount": "InviteCount",
                    "FreeMailInvites": "FreeMailInvites",
                    "InvitesLast24h": "InvitesLast24h",
                    "GuestDomains": "GuestDomains",
                    "InviterDept": "InviterDepartment",
                    "InviterRoles": "InviterRoles",
                    "InviterType": "InviterType"
                },
                "entityMappings": [
                    {
                        "entityType": "Account",
                        "fieldMappings": [
                            {
                                "identifier": "Name",
                                "columnName": "InviterName"
                            },
                            {
                                "identifier": "UPNSuffix",
                                "columnName": "InviterUPNSuffix"
                            }
                        ]
                    },
                    {
                        "entityType": "Account",
                        "fieldMappings": [
                            {
                                "identifier": "Name",
                                "columnName": "GuestName"
                            },
                            {
                                "identifier": "UPNSuffix",
                                "columnName": "GuestUPNSuffix"
                            }
                        ]
                    },
                    {
                        "entityType": "IP",
                        "fieldMappings": [
                            {
                                "identifier": "Address",
                                "columnName": "InviterIP"
                            }
                        ]
                    }
                ],
                "sentinelEntitiesMappings": null,
                "templateVersion": null
            }
        }
    ]
}
