{
    "$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/application-auth-surface-tampering')]",
            "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/application-auth-surface-tampering')]",
            "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
            "kind": "Scheduled",
            "apiVersion": "2023-12-01-preview",
            "properties": {
                "displayName": "Application Authentication Surface Tampering",
                "description": "Detects tampering with the authentication surface of Entra applications: redirect URI additions outside trusted domains, wildcard redirect URIs, logout URL removal and Application ID URI changes. Weighted risk scoring keeps developer localhost entries, allowlisted automation and routine changes below the alert gate. Replaces three content hub rules (Changes to Application Logout URL, Application Redirect URL Update, Application ID URI Changed) with one scored detection.\n\nTABLES: AuditLogs, IdentityInfo\nCONNECTORS: Microsoft Entra ID (AuditLogs); UEBA for IdentityInfo\nLICENSE: Entra ID P1 for AuditLogs export; UEBA enabled for IdentityInfo, the role signal stays silent without it\n\nTUNING:\n1. Query period must be P14D - the 14d dormancy and identity lookups only resolve if the rule looks back that far.\n2. TrustedDomains: populate with organisation-owned redirect domains before enabling, otherwise every legitimate redirect scores as untrusted.\n3. TrustedAutomation: service principal ids of CI/CD pipelines allowed to modify app registrations.\n4. DetectionWindow must equal the rule frequency (PT1H) so each change is scored exactly once.\n5. FireThreshold: raise to 9 to require a serious signal or a three-signal stack.\n\nKNOWN FALSE POSITIVES:\n1. Developers adding localhost redirects - localhost and 127.0.0.1 are exempt from redirect signals.\n2. Deployment pipelines updating redirect URIs - allowlist the service principal in TrustedAutomation.\n3. New SaaS integrations added by admins - a single untrusted redirect scores 5 and stays below the gate of 8.\n\nINVESTIGATION STEPS:\n1. Confirm with the initiator whether the change was planned.\n2. Check who owns the target application and what permissions it holds.\n3. Review sign-ins and token grants for the application after the change time.\n4. If a redirect points outside the organisation, revert the change and revoke refresh tokens for affected users.\n5. For logout URL removal, check for long-lived sessions on the application.\n\nBLIND SPOTS:\nAn admin-credentialed change adding one untrusted redirect to an actively maintained app scores 5 and stays silent. A compromised allowlisted automation service principal bypasses the app-initiated signal. Homepage and error URL changes are excluded on purpose. Credential and secret additions belong to a separate rule. The rule sees the configuration change, not the token theft that follows; pair it with sign-in anomaly detections.\n\nAuthor: Bartosz Wysocki | https://www.itprofessor.cloud\nVersion: 1.0 | 2026-07-24",
                "severity": "Medium",
                "enabled": true,
                "query": "// =====================================================================\n// Application Authentication Surface Tampering\n// =====================================================================\n// Tables  : AuditLogs, IdentityInfo\n// License : Entra ID P1 for AuditLogs; UEBA for IdentityInfo, role signal silent without it\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 equal the rule frequency so each change is scored exactly once\nlet HistoryLookback = 14d;                      // how far back the app dormancy baseline reaches\nlet IdentityLookback = 14d;                     // window for the freshest IdentityInfo snapshot\nlet TrustedDomains = dynamic([\"yourcompany.com\", \"yourcompany.onmicrosoft.com\"]); // organisation-owned redirect domains, populate before enabling\nlet TrustedAutomation = dynamic([]);            // service principal ids permitted to modify app registrations (CI/CD)\nlet AppAdminRoles = dynamic([\"Global Administrator\", \"Application Administrator\", \"Cloud Application Administrator\"]); // roles expected to manage applications\n// --- Scoring: serious signals clear FireThreshold alone, soft signals only stack ---\nlet W_WildcardRedirect = 9;                     // wildcard inside an added redirect URI\nlet W_UntrustedRedirect = 5;                    // redirect added outside TrustedDomains\nlet W_InsecureRedirect = 3;                     // plain http redirect, localhost exempt\nlet W_LogoutRemoved = 5;                        // logout URL blanked or removed\nlet W_AppIdUriChanged = 4;                      // Application ID URI modified\nlet W_DormantApp = 3;                           // no configuration changes to this app in the prior window\nlet W_NonAdminActor = 3;                        // initiator holds no application admin role (owner path)\nlet W_AppInitiated = 3;                         // change made by a service principal outside TrustedAutomation\nlet FireThreshold = 8;                          // minimum RiskScore required to alert\n// --- Successful application configuration changes across the full query period ---\nlet BaseChanges = materialize(\n    AuditLogs\n    | where TimeGenerated > ago(HistoryLookback)\n    | where Category =~ \"ApplicationManagement\"\n    | where Result =~ \"success\"\n    | where OperationName in~ (\"Update Application\", \"Update Service principal\")\n    | mv-apply Target = TargetResources on (\n        where tostring(Target.type) in~ (\"Application\", \"ServicePrincipal\")\n        | project TargetAppName = tostring(Target.displayName),\n                  TargetObjectId = tostring(Target.id),\n                  ModProps = Target.modifiedProperties\n    )\n    | extend InitiatorUPN = tostring(InitiatedBy.user.userPrincipalName),\n             InitiatorId = tostring(InitiatedBy.user.id),\n             InitiatorIP = tostring(InitiatedBy.user.ipAddress),\n             InitiatorApp = tostring(InitiatedBy.app.displayName),\n             InitiatorSPId = tostring(InitiatedBy.app.servicePrincipalId),\n             UserAgent = extract(@'\"User-Agent\"[^}]*\"value\"\\s*:\\s*\"([^\"]+)\"', 1, tostring(AdditionalDetails))\n);\n// --- Dormancy baseline: apps touched before the detection window; scores only when prior data exists ---\nlet PriorTouchedApps = toscalar(\n    BaseChanges\n    | where TimeGenerated <= ago(DetectionWindow)\n    | summarize make_set(TargetObjectId, 100000)\n);\n// --- URL and URI properties changed inside the detection window ---\nlet WindowProps = materialize(\n    BaseChanges\n    | where TimeGenerated > ago(DetectionWindow)\n    | mv-apply Prop = ModProps on (\n        where tostring(Prop.displayName) in~ (\"AppAddress\", \"LogoutUrl\", \"Logout Url\", \"AppIdentifierUri\")\n        | project PropName = tostring(Prop.displayName),\n                  OldValue = tostring(Prop.oldValue),\n                  NewValue = tostring(Prop.newValue)\n    )\n);\n// --- AppAddress: diff old against new, classify added and removed entries by address type ---\nlet AddressRows = materialize(\n    WindowProps\n    | where PropName =~ \"AppAddress\"\n    | extend AddedEntries = set_difference(todynamic(NewValue), todynamic(OldValue)),\n             RemovedEntries = set_difference(todynamic(OldValue), todynamic(NewValue))\n);\nlet AddressDiff = union\n    (AddressRows | mv-expand Entry = AddedEntries | extend Direction = \"Added\"),\n    (AddressRows | mv-expand Entry = RemovedEntries | extend Direction = \"Removed\")\n    | extend Address = tostring(Entry.Address),\n             AddressKind = case(tostring(Entry.AddressType) == \"0\", \"Redirect\",\n                                tostring(Entry.AddressType) == \"1\", \"Homepage\",\n                                tostring(Entry.AddressType) == \"2\", \"Logout\",\n                                \"Other\")\n    | where isnotempty(Address)\n    | summarize AddedRedirects = make_set_if(Address, Direction == \"Added\" and AddressKind == \"Redirect\"),\n                RemovedRedirects = make_set_if(Address, Direction == \"Removed\" and AddressKind == \"Redirect\"),\n                AddedLogoutUrls = make_set_if(Address, Direction == \"Added\" and AddressKind == \"Logout\"),\n                RemovedLogoutUrls = make_set_if(Address, Direction == \"Removed\" and AddressKind == \"Logout\")\n            by CorrelationId, TargetObjectId;\n// --- LogoutUrl and AppIdentifierUri recorded as flat properties ---\nlet SimpleChanges = WindowProps\n| where PropName !~ \"AppAddress\"\n| summarize OldLogoutUrl = trim(@'[\\[\\]\"\\s]+', maxif(OldValue, PropName has \"Logout\")),\n            NewLogoutUrl = trim(@'[\\[\\]\"\\s]+', maxif(NewValue, PropName has \"Logout\")),\n            OldAppIdUri = trim(@'[\\[\\]\"\\s]+', maxif(OldValue, PropName =~ \"AppIdentifierUri\")),\n            NewAppIdUri = trim(@'[\\[\\]\"\\s]+', maxif(NewValue, PropName =~ \"AppIdentifierUri\"))\n        by CorrelationId, TargetObjectId;\n// --- One row per change operation with initiator metadata ---\nlet ChangeMeta = WindowProps\n| summarize EndTime = max(TimeGenerated),\n            take_any(TargetAppName, InitiatorUPN, InitiatorId, InitiatorIP, InitiatorApp, InitiatorSPId, UserAgent, OperationName)\n        by CorrelationId, TargetObjectId;\n// --- Latest identity snapshot for the initiating user ---\nlet ActorIdentity = IdentityInfo\n| where TimeGenerated > ago(IdentityLookback)\n| where isnotempty(AccountUPN)\n| summarize arg_max(TimeGenerated, JobTitle, Department, AssignedRoles) by AccountUPN\n| project ActorUPNKey = tolower(AccountUPN), ActorJobTitle = JobTitle, ActorDepartment = Department, ActorRoles = AssignedRoles, IdentityFound = true;\nChangeMeta\n| join kind=leftouter AddressDiff on CorrelationId, TargetObjectId\n| join kind=leftouter SimpleChanges on CorrelationId, TargetObjectId\n| extend ActorUPNKey = tolower(InitiatorUPN)\n| join kind=leftouter ActorIdentity on ActorUPNKey\n| extend AddedRedirects = coalesce(AddedRedirects, dynamic([])),\n         RemovedRedirects = coalesce(RemovedRedirects, dynamic([])),\n         AddedLogoutUrls = coalesce(AddedLogoutUrls, dynamic([])),\n         RemovedLogoutUrls = coalesce(RemovedLogoutUrls, dynamic([])),\n         OldLogoutUrl = coalesce(OldLogoutUrl, \"\"),\n         NewLogoutUrl = coalesce(NewLogoutUrl, \"\"),\n         OldAppIdUri = coalesce(OldAppIdUri, \"\"),\n         NewAppIdUri = coalesce(NewAppIdUri, \"\")\n// --- Classify added redirects; the probe placeholder keeps rows with no additions alive through mv-apply ---\n| extend RedirectProbe = iff(array_length(AddedRedirects) == 0, dynamic([\"\"]), AddedRedirects)\n| mv-apply Candidate = RedirectProbe to typeof(string) on (\n    summarize UntrustedRedirects = make_set_if(Candidate, isnotempty(Candidate) and not(Candidate has_any (TrustedDomains)) and Candidate !contains \"localhost\" and Candidate !contains \"127.0.0.1\"),\n              InsecureRedirects = make_set_if(Candidate, isnotempty(Candidate) and Candidate startswith \"http://\" and Candidate !contains \"localhost\" and Candidate !contains \"127.0.0.1\"),\n              WildcardRedirects = make_set_if(Candidate, isnotempty(Candidate) and Candidate contains \"*\")\n)\n// --- Signal evaluation ---\n| extend LogoutRemoved = (isnotempty(OldLogoutUrl) and (isempty(NewLogoutUrl) or NewLogoutUrl =~ \"null\"))\n                      or (array_length(RemovedLogoutUrls) > 0 and array_length(AddedLogoutUrls) == 0),\n         AppIdUriChanged = (isnotempty(OldAppIdUri) or isnotempty(NewAppIdUri)) and OldAppIdUri != NewAppIdUri,\n         DormantApp = array_length(PriorTouchedApps) > 0 and not(set_has_element(PriorTouchedApps, TargetObjectId)),\n         NonAdminActor = IdentityFound == true and isnotempty(InitiatorUPN) and not(tostring(ActorRoles) has_any (AppAdminRoles)),\n         AppInitiated = isempty(InitiatorUPN) and isnotempty(InitiatorSPId) and InitiatorSPId !in (TrustedAutomation)\n// --- Score the change ---\n| extend RiskScore =\n      iff(array_length(WildcardRedirects) > 0, W_WildcardRedirect, 0)\n    + iff(array_length(UntrustedRedirects) > 0, W_UntrustedRedirect, 0)\n    + iff(array_length(InsecureRedirects) > 0, W_InsecureRedirect, 0)\n    + iff(LogoutRemoved, W_LogoutRemoved, 0)\n    + iff(AppIdUriChanged, W_AppIdUriChanged, 0)\n    + iff(DormantApp, W_DormantApp, 0)\n    + iff(NonAdminActor, W_NonAdminActor, 0)\n    + iff(AppInitiated, W_AppInitiated, 0)\n| where RiskScore >= FireThreshold\n| extend Risk_1 = iff(array_length(WildcardRedirects) > 0, \"Wildcard redirect URI added\", \"\"),\n         Risk_2 = iff(array_length(UntrustedRedirects) > 0, \"Redirect added outside trusted domains\", \"\"),\n         Risk_3 = iff(array_length(InsecureRedirects) > 0, \"Plain http redirect added\", \"\"),\n         Risk_4 = iff(LogoutRemoved, \"Logout URL removed or blanked\", \"\"),\n         Risk_5 = iff(AppIdUriChanged, \"Application ID URI changed\", \"\"),\n         Risk_6 = iff(DormantApp, \"App untouched for 14 days before this change\", \"\"),\n         Risk_7 = iff(NonAdminActor, \"Initiator holds no application admin role\", \"\"),\n         Risk_8 = iff(AppInitiated, \"Change made by a non-allowlisted service principal\", \"\")\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| extend UpdatedBy = iff(isnotempty(InitiatorUPN), InitiatorUPN, InitiatorApp),\n         InitiatorName = tostring(split(InitiatorUPN, \"@\", 0)[0]),\n         InitiatorUPNSuffix = tostring(split(InitiatorUPN, \"@\", 1)[0]),\n         PrimarySuspectUrl = iff(array_length(WildcardRedirects) > 0, tostring(WildcardRedirects[0]), tostring(UntrustedRedirects[0]))\n| project EndTime, TargetAppName, TargetObjectId, OperationName, UpdatedBy, InitiatorUPN, InitiatorName, InitiatorUPNSuffix, InitiatorId, InitiatorIP, InitiatorApp, InitiatorSPId, UserAgent, ActorJobTitle, ActorDepartment, ActorRoles, AddedRedirects, RemovedRedirects, UntrustedRedirects, InsecureRedirects, WildcardRedirects, OldLogoutUrl, NewLogoutUrl, OldAppIdUri, NewAppIdUri, PrimarySuspectUrl, RiskScore, RiskIndicators\n| sort by RiskScore desc, EndTime desc",
                "queryFrequency": "PT1H",
                "queryPeriod": "P14D",
                "triggerOperator": "GreaterThan",
                "triggerThreshold": 0,
                "suppressionDuration": "PT1H",
                "suppressionEnabled": false,
                "startTimeUtc": null,
                "tactics": [
                    "CredentialAccess",
                    "Persistence"
                ],
                "techniques": [
                    "T1528",
                    "T1098"
                ],
                "subTechniques": [],
                "alertRuleTemplateName": null,
                "incidentConfiguration": {
                    "createIncident": true,
                    "groupingConfiguration": {
                        "enabled": true,
                        "reopenClosedIncident": false,
                        "lookbackDuration": "PT6H",
                        "matchingMethod": "Selected",
                        "groupByEntities": [
                            "Account"
                        ],
                        "groupByAlertDetails": null,
                        "groupByCustomDetails": null
                    }
                },
                "eventGroupingSettings": {
                    "aggregationKind": "AlertPerResult"
                },
                "alertDetailsOverride": {
                    "alertDisplayNameFormat": "Application configuration tampering: {{TargetAppName}}",
                    "alertDescriptionFormat": "{{UpdatedBy}} modified authentication settings. Risk score {{RiskScore}}. Signals: {{RiskIndicators}}"
                },
                "customDetails": {
                    "TargetAppId": "TargetObjectId",
                    "AddedRedirects": "AddedRedirects",
                    "RemovedRedirects": "RemovedRedirects",
                    "UntrustedRedirects": "UntrustedRedirects",
                    "WildcardRedirects": "WildcardRedirects",
                    "OldLogoutUrl": "OldLogoutUrl",
                    "NewLogoutUrl": "NewLogoutUrl",
                    "OldAppIdUri": "OldAppIdUri",
                    "NewAppIdUri": "NewAppIdUri",
                    "ActorRoles": "ActorRoles",
                    "ActorDepartment": "ActorDepartment",
                    "UserAgent": "UserAgent",
                    "Operation": "OperationName"
                },
                "entityMappings": [
                    {
                        "entityType": "Account",
                        "fieldMappings": [
                            {
                                "identifier": "Name",
                                "columnName": "InitiatorName"
                            },
                            {
                                "identifier": "UPNSuffix",
                                "columnName": "InitiatorUPNSuffix"
                            }
                        ]
                    },
                    {
                        "entityType": "IP",
                        "fieldMappings": [
                            {
                                "identifier": "Address",
                                "columnName": "InitiatorIP"
                            }
                        ]
                    },
                    {
                        "entityType": "CloudApplication",
                        "fieldMappings": [
                            {
                                "identifier": "Name",
                                "columnName": "TargetAppName"
                            }
                        ]
                    },
                    {
                        "entityType": "URL",
                        "fieldMappings": [
                            {
                                "identifier": "Url",
                                "columnName": "PrimarySuspectUrl"
                            }
                        ]
                    }
                ],
                "sentinelEntitiesMappings": null,
                "templateVersion": null
            }
        }
    ]
}
