{
    "$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/sharepoint-previously-unseen-user-agents')]",
            "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/sharepoint-previously-unseen-user-agents')]",
            "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
            "kind": "Scheduled",
            "apiVersion": "2023-12-01-preview",
            "properties": {
                "displayName": "SharePoint Mass File Access From a First-Time Client",
                "description": "Detects an account moving SharePoint or OneDrive documents through a client family it has not used in the preceding 14 days, where the volume, the client type, the site spread or the directory state make the activity worth an investigation. Client strings are normalised to a version free family so browser and Office updates do not register as new clients, and novelty is only trusted for accounts with established history, so an empty or broken baseline degrades the rule to silence rather than to a flood.\n\nTABLES: OfficeActivity, IdentityInfo\nCONNECTORS: Office 365 (SharePoint); Microsoft Sentinel UEBA for IdentityInfo\nLICENSE: Office 365 E3 or above for the audit data. IdentityInfo requires UEBA to be enabled, Entra ID P1 minimum. Without IdentityInfo the disabled, guest and privileged signals go silent and the rule continues on client and volume signals.\n\nTUNING:\n1. Query period must be P14D. The 14d client and site baseline only resolves if the rule itself looks back that far.\n2. DetectionWindow must equal the run frequency. Both are 1 day as shipped. Change one and you must change the other.\n3. IngestionLag (12h) must be at least the worst OfficeActivity ingestion delay in the tenant. Measure with extend Lag = ingestion_time() - TimeGenerated and take P99. Set too low, late events are dropped permanently.\n4. MinFileCount (25) is the floor for a row to be scored. Raise it to cut volume, lower it to catch slower retrieval.\n5. BulkFileCount (500) is the only signal that fires on its own. Set it above the largest legitimate single day browser download in your own data.\n6. RolloutUsers (10) mutes the client novelty weights when many accounts adopt the same new client in one window. Raise it in large tenants.\n\nKNOWN FALSE POSITIVES:\n1. Browser and Office version upgrades. Handled by the ClientFamily normaliser, which strips digit runs before comparison, so Chrome 139 and Chrome 140 are one family.\n2. Content migration tooling such as ShareGate or Mover run under a dedicated account. The account has no 14 day baseline on day one so it is dropped, and by day two the tool client sits in its own history. Exposure is at most one run.\n3. Sanctioned bulk clients deployed to many accounts at once, for example a backup agent. Handled by the rollout suppression, which zeroes the client and rare weights and demotes bulk volume when RolloutUsers accounts or more adopt the same client in one window.\n4. Guests opening a shared library from a personal browser. Scores 30 and stays silent unless the client is also unrecognised and the volume is high.\n5. A disabled account whose tokens have not yet expired. Sync operations are out of scope and the disabled signal is worth 60, below the threshold, so it never fires on its own.\n\nINVESTIGATION STEPS:\n1. Read RiskIndicators first. It names every signal that contributed to the score.\n2. Compare UserAgent against the account's normal clients. A library or scripting client on a user account is the strongest single tell.\n3. Check NewSites. Sites the account has not touched in 14 days point to enumeration rather than normal work.\n4. Trace the sign-in behind the source IP in SigninLogs and AADNonInteractiveUserSignInLogs. Establish whether an interactive authentication happened or whether a token was replayed.\n5. Confirm with the named manager whether the retrieval was expected, especially where the job title or department make a bulk pull unusual.\n6. If the account is disabled, treat it as confirmed until proven otherwise and revoke sessions.\n\nBLIND SPOTS:\nAn operator who copies the victim's own user agent is invisible, because the client family is not new for that account. Sync operations (FileSyncDownloadedFull and relatives) are out of scope and belong to a companion rule. Records with an empty UserAgent are dropped, which is a large share of app only and API traffic. Accounts with fewer than MinBaselineEvents events in 14 days are excluded, so new starters are uncovered for their first fortnight. A client adopted by RolloutUsers accounts or more in one window loses its novelty weight, so a simultaneous compromise of that many accounts with identical tooling scores lower. Retrieval below MinFileCount is never scored.\n\nAuthor: Bartosz Wysocki | https://www.itprofessor.cloud\nVersion: 1.0 | 2026-08-14",
                "severity": "Medium",
                "enabled": true,
                "query": "// =====================================================================\n// SharePoint Mass File Access From a First-Time Client\n// =====================================================================\n// Tables  : OfficeActivity, IdentityInfo\n// License : Office 365 audit (E3 or above); IdentityInfo requires Sentinel UEBA\n// Tuning  : see rule description for full tuning notes\n// Author  : Bartosz Wysocki | itprofessor.cloud\n// Version : 1.0 | 2026-08-14\n// =====================================================================\n// Which events may fire: must equal the rule's run frequency\nlet DetectionWindow    = 1d;\n// Real ingestion delay of OfficeActivity; widens sight, never firing\nlet IngestionLag       = 12h;\n// How far back client and site history is built\nlet BaselineWindow     = 14d;\n// How far back directory context is read\nlet IdentityLookback   = 7d;\n// Distinct files an account must touch through the new client to be scored\nlet MinFileCount       = 25;\n// Distinct files that count as a high volume day\nlet HighFileCount      = 100;\n// Distinct files that count as bulk retrieval\nlet BulkFileCount      = 500;\n// History an account needs before an absent client family means anything\nlet MinBaselineEvents  = 50;\n// Accounts a client family may already cover and still count as rare\nlet RareClientUsers    = 3;\n// New adopters in one window above which a client is treated as a rollout\nlet RolloutUsers       = 10;\n// Sites with no account history before the spread signal counts\nlet NewSiteThreshold   = 3;\n// Score at which a row becomes an incident\nlet FireThreshold      = 100;\n// Scoring: bulk retrieval fires alone, every other signal only stacks\nlet W_ScriptedClient   = 45;\nlet W_UnknownClient    = 25;\nlet W_RareClient       = 35;\nlet W_VolumeBulk       = 100;\nlet W_VolumeHigh       = 40;\nlet W_NewSiteSpread    = 25;\nlet W_DisabledAccount  = 60;\nlet W_GuestAccount     = 30;\nlet W_PrivilegedRole   = 25;\n// Operations in scope; sync operations belong to a companion rule\nlet FileOperations     = dynamic([\"FileDownloaded\", \"FileUploaded\"]);\n// SharePoint records its own service activity under these names\nlet SystemAccounts     = dynamic([\"app@sharepoint\", \"system\"]);\n// Client strings that identify scripted or library based access\nlet ScriptedPattern    = \"(python|urllib|requests|curl|wget|libwww|go-http|okhttp|java/|httpclient|axios|node-fetch|httpx|aiohttp|powershell|restsharp|rclone|postman|insomnia|msal)\";\n// Client strings that identify a browser or a Microsoft first party client\nlet KnownClientPattern = \"(mozilla|applewebkit|gecko|chrome|safari|edg|firefox|opera|microsoft office|skydrivesync|onedrive|sharepoint|outlook|teams|excel|word|powerpoint|onenote)\";\n// Strips version numbers so a browser update does not read as a new client\nlet ClientFamily = (ua:string) {\n    trim(\" +\", replace_regex(replace_regex(tolower(ua), \"[0-9]+\", \"\"), \"[. _]+\", \" \"))\n};\n// Widest event age the seed may look at\nlet SeedWindow = DetectionWindow + IngestionLag;\n// Client and site history, ending before the seed can see anything\nlet BaselineGrain = materialize(\n    OfficeActivity\n    | where TimeGenerated between (ago(BaselineWindow) .. ago(SeedWindow))\n    | where RecordType == \"SharePointFileOperation\"\n    | where Operation in (FileOperations)\n    | where isnotempty(UserAgent)\n    | where isnotempty(UserId)\n    | extend UserId = tolower(UserId)\n    | where UserId !in (SystemAccounts)\n    | extend ClientId = ClientFamily(UserAgent)\n    | summarize BaselineEvents = count() by UserId, ClientId, Site_Url\n);\n// Client families each account has already used\nlet KnownClients = BaselineGrain\n    | distinct UserId, ClientId;\n// Sites each account has already touched\nlet KnownSites = BaselineGrain\n    | distinct UserId, Site_Url;\n// How many accounts each client family already covers across the tenant\nlet ClientReach = BaselineGrain\n    | summarize BaselineUsers = dcount(UserId) by ClientId;\n// Accounts with enough history for a first time client to mean anything\nlet BaselinedUsers = BaselineGrain\n    | summarize UserBaselineEvents = sum(BaselineEvents) by UserId\n    | where UserBaselineEvents >= MinBaselineEvents;\n// Directory context; without UEBA these signals go silent, never wrong\nlet Identity = IdentityInfo\n    | where TimeGenerated >= ago(IdentityLookback)\n    | where isnotempty(AccountUPN)\n    | extend UserId = tolower(AccountUPN)\n    | summarize arg_max(TimeGenerated, AccountDisplayName, JobTitle, Department, Manager,\n                        IsAccountEnabled, AssignedRoles, UserType) by UserId\n    | project UserId, AccountDisplayName, JobTitle, Department, Manager,\n              IsDisabled   = isnotnull(IsAccountEnabled) and IsAccountEnabled == false,\n              IsDirGuest   = UserType =~ \"Guest\",\n              IsPrivileged = coalesce(array_length(todynamic(AssignedRoles)), 0) > 0;\n// Accounts using a client family they have no history with\nlet Candidates = materialize(\n    OfficeActivity\n    | extend IngestedAt = ingestion_time()\n    | where TimeGenerated >= ago(SeedWindow)\n    | where IngestedAt >= ago(DetectionWindow)\n    | where RecordType == \"SharePointFileOperation\"\n    | where Operation in (FileOperations)\n    | where isnotempty(UserAgent)\n    | where isnotempty(UserId)\n    | extend UserId = tolower(UserId)\n    | where UserId !in (SystemAccounts)\n    | extend ClientId = ClientFamily(UserAgent)\n    | project TimeGenerated, UserId, ClientId, UserAgent, Operation, Site_Url,\n              OfficeObjectId, ClientIP, OfficeWorkload\n    | lookup kind=leftouter (KnownSites | extend SiteKnown = true) on UserId, Site_Url\n    | extend SiteKnown = coalesce(SiteKnown, false)\n    | summarize\n        StartTime     = min(TimeGenerated),\n        EndTime       = max(TimeGenerated),\n        FileCount     = dcount(OfficeObjectId),\n        DownloadCount = countif(Operation == \"FileDownloaded\"),\n        UploadCount   = countif(Operation == \"FileUploaded\"),\n        SiteCount     = dcount(Site_Url),\n        NewSiteCount  = dcountif(Site_Url, SiteKnown == false),\n        NewSites      = make_set_if(Site_Url, SiteKnown == false, 5),\n        SourceIPs     = make_set(ClientIP, 5),\n        IPCount       = dcount(ClientIP),\n        RawAgent      = take_any(UserAgent),\n        Workloads     = make_set(OfficeWorkload, 3)\n      by UserId, ClientId\n    | where FileCount >= MinFileCount\n    | lookup kind=inner (BaselinedUsers) on UserId\n    | join kind=leftanti (KnownClients) on UserId, ClientId\n);\n// How many accounts newly adopted each client family inside this window\nlet ClientAdoption = Candidates\n    | summarize WindowAdopters = dcount(UserId) by ClientId;\nCandidates\n| lookup kind=leftouter (ClientReach) on ClientId\n| lookup kind=leftouter (ClientAdoption) on ClientId\n| lookup kind=leftouter (Identity) on UserId\n| extend BaselineUsers  = coalesce(BaselineUsers, 0)\n| extend WindowAdopters = coalesce(WindowAdopters, 1)\n| extend IsRollout      = WindowAdopters >= RolloutUsers\n| extend IsScripted     = ClientId matches regex ScriptedPattern\n| extend IsKnownClient  = ClientId matches regex KnownClientPattern\n| extend IsRareClient   = BaselineUsers < RareClientUsers and not(IsRollout)\n| extend IsGuest        = coalesce(IsDirGuest, false) or UserId contains \"#ext#\"\n| extend IsDisabled     = coalesce(IsDisabled, false)\n| extend IsPrivileged   = coalesce(IsPrivileged, false)\n| extend ClientWeight   = case(IsRollout, 0,\n                               IsScripted, W_ScriptedClient,\n                               not(IsKnownClient), W_UnknownClient,\n                               0)\n| extend VolumeWeight   = case(FileCount >= BulkFileCount and not(IsRollout), W_VolumeBulk,\n                               FileCount >= HighFileCount, W_VolumeHigh,\n                               0)\n| extend RiskScore = ClientWeight + VolumeWeight\n    + iff(IsRareClient, W_RareClient, 0)\n    + iff(NewSiteCount >= NewSiteThreshold, W_NewSiteSpread, 0)\n    + iff(IsDisabled, W_DisabledAccount, 0)\n    + iff(IsGuest, W_GuestAccount, 0)\n    + iff(IsPrivileged, W_PrivilegedRole, 0)\n| where RiskScore >= FireThreshold\n| extend Risk_1 = iff(ClientWeight == W_ScriptedClient, strcat(\"Scripted client: \", ClientId), \"\")\n| extend Risk_2 = iff(ClientWeight == W_UnknownClient, strcat(\"Unrecognised client: \", ClientId), \"\")\n| extend Risk_3 = iff(IsRareClient, strcat(\"Client rare in tenant (\", tostring(BaselineUsers), \" prior accounts)\"), \"\")\n| extend Risk_4 = case(VolumeWeight == W_VolumeBulk, strcat(\"Bulk retrieval: \", tostring(FileCount), \" files\"),\n                       VolumeWeight == W_VolumeHigh, strcat(\"High volume: \", tostring(FileCount), \" files\"),\n                       \"\")\n| extend Risk_5 = iff(NewSiteCount >= NewSiteThreshold, strcat(\"Reached \", tostring(NewSiteCount), \" sites with no account history\"), \"\")\n| extend Risk_6 = iff(IsDisabled, \"Account disabled in the directory\", \"\")\n| extend Risk_7 = iff(IsGuest, \"Guest account\", \"\")\n| extend Risk_8 = iff(IsPrivileged, \"Account holds a directory role\", \"\")\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    iff(isnotempty(Risk_7), strcat(Risk_7, \" | \"), \"\"),\n    iff(isnotempty(Risk_8), strcat(Risk_8, \" | \"), \"\")\n))\n| extend GuestName        = extract(\"^(.+)_[^_]+#ext#\", 1, UserId)\n| extend GuestDomain      = extract(\"_([^_]+)#ext#\", 1, UserId)\n| extend AccountName      = iff(isnotempty(GuestName), GuestName, tostring(split(UserId, \"@\", 0)[0]))\n| extend AccountUPNSuffix = iff(isnotempty(GuestDomain), GuestDomain, tostring(split(UserId, \"@\", 1)[0]))\n| extend PrimaryIP        = tostring(SourceIPs[0])\n| project StartTime, EndTime, RiskScore, RiskIndicators, UserId, AccountName, AccountUPNSuffix,\n          AccountDisplayName, JobTitle, Department, Manager, ClientId, RawAgent,\n          FileCount, DownloadCount, UploadCount, SiteCount, NewSiteCount, NewSites,\n          IPCount, PrimaryIP, SourceIPs, Workloads, BaselineUsers, WindowAdopters, UserBaselineEvents\n| sort by RiskScore desc, EndTime desc",
                "queryFrequency": "P1D",
                "queryPeriod": "P14D",
                "triggerOperator": "GreaterThan",
                "triggerThreshold": 0,
                "suppressionDuration": "PT1H",
                "suppressionEnabled": false,
                "startTimeUtc": null,
                "tactics": [
                    "Collection",
                    "Exfiltration"
                ],
                "techniques": [
                    "T1213",
                    "T1530",
                    "T1119",
                    "T1567"
                ],
                "subTechniques": [
                    "T1213.002"
                ],
                "alertRuleTemplateName": null,
                "incidentConfiguration": {
                    "createIncident": true,
                    "groupingConfiguration": {
                        "enabled": true,
                        "reopenClosedIncident": false,
                        "lookbackDuration": "PT24H",
                        "matchingMethod": "Selected",
                        "groupByEntities": [
                            "Account"
                        ],
                        "groupByAlertDetails": null,
                        "groupByCustomDetails": null
                    }
                },
                "eventGroupingSettings": {
                    "aggregationKind": "AlertPerResult"
                },
                "alertDetailsOverride": {
                    "alertDescriptionFormat": "{{UserId}} moved {{FileCount}} documents through a client family the account has not used in the last 14 days. Indicators: {{RiskIndicators}}"
                },
                "customDetails": {
                    "RiskScore": "RiskScore",
                    "RiskIndicators": "RiskIndicators",
                    "ClientFamily": "ClientId",
                    "UserAgent": "RawAgent",
                    "FileCount": "FileCount",
                    "DownloadCount": "DownloadCount",
                    "UploadCount": "UploadCount",
                    "SiteCount": "SiteCount",
                    "NewSiteCount": "NewSiteCount",
                    "NewSites": "NewSites",
                    "SourceIPs": "SourceIPs",
                    "IPCount": "IPCount",
                    "Department": "Department",
                    "JobTitle": "JobTitle",
                    "Manager": "Manager",
                    "Workloads": "Workloads",
                    "TenantAccountsSeen": "BaselineUsers",
                    "NewAdoptersInWindow": "WindowAdopters",
                    "UserBaselineEvents": "UserBaselineEvents"
                },
                "entityMappings": [
                    {
                        "entityType": "Account",
                        "fieldMappings": [
                            {
                                "identifier": "Name",
                                "columnName": "AccountName"
                            },
                            {
                                "identifier": "UPNSuffix",
                                "columnName": "AccountUPNSuffix"
                            }
                        ]
                    },
                    {
                        "entityType": "IP",
                        "fieldMappings": [
                            {
                                "identifier": "Address",
                                "columnName": "PrimaryIP"
                            }
                        ]
                    }
                ],
                "sentinelEntitiesMappings": null,
                "templateVersion": null
            }
        }
    ]
}
