{"id":1820,"date":"2026-03-08T13:42:04","date_gmt":"2026-03-08T13:42:04","guid":{"rendered":"https:\/\/move2modern.uk\/?p=1820"},"modified":"2026-04-06T08:30:07","modified_gmt":"2026-04-06T08:30:07","slug":"alternative-approaches-to-utcm","status":"publish","type":"post","link":"https:\/\/move2modern.uk\/index.php\/2026\/03\/08\/alternative-approaches-to-utcm\/","title":{"rendered":"Intune Alternative approaches to UTCM:"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"683\" src=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26-1024x683.png\" alt=\"\" class=\"wp-image-1840\" style=\"aspect-ratio:1.50003451846738;width:588px;height:auto\" srcset=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26-1024x683.png 1024w, https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26-300x200.png 300w, https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26-768x512.png 768w, https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26-120x80.png 120w, https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Designer-26.png 1536w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>PART 1 OF 2&nbsp;<\/p>\n\n\n\n<p>Configuration Drift Management in Microsoft Intune<\/p>\n\n\n\n<p style=\"font-size:16px\"><em><em>A practical guide to detecting and managing configuration drift using PowerShell, Power Automate, Graph API, Intune Remediations, and free community tools. No expensive third-party tools or additional licensing required.<\/em><\/em><\/p>\n\n\n\n<p><strong>Audience: <\/strong>Intermediate IT Pros&nbsp; \u00b7&nbsp; <strong>Prerequisites: <\/strong>Microsoft Intune, Entra ID, basic PowerShell<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p style=\"font-size:16px\">If you&#8217;ve been managing devices with Microsoft Intune for any meaningful length of time, you&#8217;ll already know the feeling. You spend weeks getting your compliance policies, configuration profiles, and app assignments tuned to perfection. Everything is green in the dashboard. You declare victory.<\/p>\n\n\n\n\n\n<p style=\"font-size:16px\">Then, three months later, someone opens a ticket. A device is suddenly non-compliant. A security baseline has silently drifted. A profile conflict has crept in. Somehow, between your triumphant &#8216;we&#8217;re done here&#8217; moment and right now, your carefully configured fleet has developed opinions of its own. Welcome to configuration drift.<\/p>\n\n\n\n<div style=\"height:15px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<p style=\"font-size:16px\">This is Part 1 of a two-part series. Here we&#8217;ll cover the practical, mostly free approaches available to IT Pros managing drift with Intune tooling \u2014 PowerShell, Power Automate, Graph API, Intune&#8217;s own built-in features and a selection of <strong>free community tools<\/strong> that do a lot of the heavy lifting for you. These are not workarounds or stopgaps \u2014 they are genuinely capable approaches that many mature Intune environments rely on. Then in Part 2 I\u2019ll cover my experience with Microsoft&#8217;s Unified Tenant Configuration Management (<strong>UTCM<\/strong>), the emerging Microsoft native answer to this problem.<\/p>\n\n\n\n<p style=\"font-size:16px\">Let&#8217;s get into it.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 1&nbsp; \u00b7&nbsp; <strong>What Is Configuration Drift and Why Should You Care?<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Understanding the Problem Space<\/h2>\n\n\n\n<p style=\"font-size:16px\">Configuration drift in an Intune context can show up in a few distinct ways, and it&#8217;s worth being precise about each, that\u2019s because the right detection method depends on what kind of drift you&#8217;re actually dealing with.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\">Policy non-compliance &#8211; A device that was previously compliant is no longer meeting one or more compliance policy requirements. The device is still enrolled and receiving policies, but its state has diverged.<\/li>\n\n\n\n<li style=\"font-size:16px\">Profile drift &#8211; A configuration profile is assigned and reporting as Succeeded in Intune, yet the underlying setting on the device doesn&#8217;t match the intended value. This is more common than people realise, especially with CSP-based policies.<\/li>\n\n\n\n<li style=\"font-size:16px\">Remediation regression &#8211; You fixed a problem on a device. The fix didn&#8217;t stick. Common with settings that apps or Windows Update can overwrite post-boot.<\/li>\n\n\n\n<li style=\"font-size:16px\">Orphaned assignments &#8211; Devices or users moved between groups, re-enrolled, or with changed licensing, leaving them with stale or missing policy assignments.<\/li>\n\n\n\n<li style=\"font-size:16px\">Baseline gaps &#8211; New devices enrolled into your tenant that haven&#8217;t fully received their intended policy stack yet, often because of sync timing or group membership delays.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">The Graph API Endpoints You&#8217;ll Use<\/h3>\n\n\n\n<p style=\"font-size:16px\">The Microsoft Graph API is your primary source of truth for device and policy state in Intune. These are the endpoints you&#8217;ll come back to throughout this guide:<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\" style=\"font-size:13px\"><table class=\"has-fixed-layout\"><thead><tr><td><strong>Endpoint<\/strong><\/td><td><strong>What It Gives You<\/strong><\/td><\/tr><\/thead><tbody><tr><td><strong>GET \/deviceManagement\/managedDevices<\/strong><\/td><td>Core device inventory \u2014 compliance state, OS version, last sync time<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/managedDevices\/{id}\/deviceConfigurationStates<\/strong><\/td><td>Per-device breakdown of which policies are in which state<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/deviceCompliancePolicies\/{id}\/deviceStatuses<\/strong><\/td><td>Compliance status per policy across all targeted devices<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/deviceConfigurations\/{id}\/deviceStatuses<\/strong><\/td><td>Configuration profile deployment states per device<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/deviceConfigurations?$expand=assignments<\/strong><\/td><td>All configuration profiles with their current group assignments<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/deviceCompliancePolicies?$expand=assignments<\/strong><\/td><td>All compliance policies with their current group assignments<\/td><\/tr><tr><td><strong>GET \/identity\/conditionalAccess\/policies<\/strong><\/td><td>All Conditional Access policies \u2014 conditions, controls, and enabled state<\/td><\/tr><tr><td><strong>GET \/deviceManagement\/configurationPolicies?$expand=assignments<\/strong><\/td><td>Settings Catalog policies (newer framework) with assignments<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-pale-ocean-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\u2139<\/strong> <strong>Graph API versions<\/strong> Most Intune automation uses the \/beta endpoint because it exposes significantly more properties than v1.0. Be aware that beta endpoints can change without notice \u2014 it&#8217;s a trade-off worth knowing about. For the Conditional Access endpoint above, use v1.0 as it is fully supported there<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p style=\"font-size:16px\">The key property to monitor on each device is <em>complianceState<\/em> on the managedDevice resource. Possible values are: compliant, noncompliant, unknown, notApplicable, inGracePeriod, and error. Anything that isn&#8217;t compliant or notApplicable should be on your radar.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 2&nbsp; \u00b7&nbsp; <strong>Approach 1: PowerShell + Graph API<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">The Classic Hammer<\/h2>\n\n\n\n<p style=\"font-size:16px\">PowerShell is the natural starting point for most IT pros. It&#8217;s familiar, flexible, doesn&#8217;t require additional licensing, and you can build something genuinely useful in an afternoon. The Microsoft.Graph PowerShell SDK provides cmdlets that wrap the Graph API \u2014 use the newer Microsoft.Graph SDK, not the old AzureAD module.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">What You&#8217;ll Need<\/h3>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\" style=\"font-size:13px\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Requirement<\/strong><\/td><td><strong>Detail<\/strong><\/td><\/tr><tr><td>PowerShell version<\/td><td>PowerShell 7.x recommended. Install via WinGet: winget install Microsoft.PowerShell<\/td><\/tr><tr><td>Graph SDK<\/td><td>Install-Module Microsoft.Graph -Scope AllUsers. For lighter installs, use individual submodules like Microsoft.Graph.DeviceManagement.<\/td><\/tr><tr><td>App Registration<\/td><td>Required for unattended\/scheduled execution. Create one in Entra ID with appropriate API permissions.<\/td><\/tr><tr><td>API permissions<\/td><td>DeviceManagementManagedDevices.Read.All and DeviceManagementConfiguration.Read.All at minimum. Add ReadWrite permissions only if you need remediation actions.<\/td><\/tr><tr><td>Intune licence<\/td><td>Intune Plan 1 (included in M365 E3\/E5, Business Premium). No additional licence needed for read-only reporting.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">A Practical Drift Detection Script<\/h3>\n\n\n\n<p style=\"font-size:16px\">The script below connects to Graph, retrieves all managed devices, and flags those with non-compliant or unknown states. It outputs to both the console and a CSV for review:<\/p>\n\n\n\n<div class=\"wp-block-file\"><a id=\"wp-block-file--media-b09edc57-2a67-41e0-8200-e2f19f584c70\" href=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Drift-detection-Script-1-2.txt\">Drift detection Script 1<\/a><a href=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Drift-detection-Script-1-2.txt\" class=\"wp-block-file__button wp-element-button\" download aria-describedby=\"wp-block-file--media-b09edc57-2a67-41e0-8200-e2f19f584c70\">Download<\/a><\/div>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><\/summary>\n<p><\/p>\n<\/details>\n\n\n\n<div data-wp-context=\"{ &quot;autoclose&quot;: false, &quot;accordionItems&quot;: [] }\" data-wp-interactive=\"core\/accordion\" role=\"group\" class=\"wp-block-accordion is-layout-flow wp-block-accordion-is-layout-flow\">\n<div data-wp-class--is-open=\"state.isOpen\" data-wp-context=\"{ &quot;id&quot;: &quot;accordion-item-1&quot;, &quot;openByDefault&quot;: false }\" data-wp-init=\"callbacks.initAccordionItems\" data-wp-on-window--hashchange=\"callbacks.hashChange\" class=\"wp-block-accordion-item has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-small-font-size is-layout-flow wp-block-accordion-item-is-layout-flow\">\n<h3 class=\"wp-block-accordion-heading has-white-color has-midnight-gradient-background has-text-color has-background has-link-color wp-elements-9ef55c64c54a4a49578ef0bd8cdfcb0c\" style=\"font-size:20px\"><button aria-expanded=\"false\" aria-controls=\"accordion-item-1-panel\" data-wp-bind--aria-expanded=\"state.isOpen\" data-wp-on--click=\"actions.toggle\" data-wp-on--keydown=\"actions.handleKeyDown\" id=\"accordion-item-1\" type=\"button\" class=\"wp-block-accordion-heading__toggle\"><span class=\"wp-block-accordion-heading__toggle-title\">PowerShell Script 1<\/span><span class=\"wp-block-accordion-heading__toggle-icon\" aria-hidden=\"true\">+<\/span><\/button><\/h3>\n\n\n\n<div inert aria-labelledby=\"accordion-item-1\" data-wp-bind--inert=\"!state.isOpen\" id=\"accordion-item-1-panel\" role=\"region\" class=\"wp-block-accordion-panel is-layout-flow wp-block-accordion-panel-is-layout-flow\">\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">Requires -Version 7.0<\/h1>\n\n\n\n<p>&lt;#<br>.SYNOPSIS<br>Intune Compliance Drift Detection<br>move2modern.co.uk \u2014 Part 1 companion script<\/p>\n\n\n\n<p>.DESCRIPTION<br>Connects interactively to Microsoft Graph, retrieves all managed devices,<br>flags those with non-compliant or unknown compliance states, displays<br>results in the console, and saves a CSV to Documents\\DriftBaselines.<\/p>\n\n\n\n<p>.NOTES<br>Required permissions (granted at sign-in prompt):<br>DeviceManagementManagedDevices.Read.All<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">&gt;<\/h1>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">SETUP<\/h1>\n\n\n\n<p>$OutputFolder = Join-Path ([Environment]::GetFolderPath(&#8216;MyDocuments&#8217;)) &#8216;DriftBaselines&#8217;<br>$OutputFile = Join-Path $OutputFolder &#8220;DriftReport_$(Get-Date -Format &#8216;yyyyMMdd_HHmmss&#8217;).csv&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">HELPERS<\/h1>\n\n\n\n<p>function Write-Header ($Text) {<br>Write-Host &#8220;&#8221;<br>Write-Host &#8221; $Text&#8221; -ForegroundColor Cyan<br>Write-Host (&#8221; &#8221; + (&#8220;\u2500&#8221; * ($Text.Length))) -ForegroundColor DarkGray<br>}<\/p>\n\n\n\n<p>function Write-Ok ($Msg) { Write-Host &#8221; [OK] $Msg&#8221; -ForegroundColor Green }<br>function Write-Warn ($Msg) { Write-Host &#8221; [!!] $Msg&#8221; -ForegroundColor Yellow }<br>function Write-Info ($Msg) { Write-Host &#8221; $Msg&#8221; -ForegroundColor Gray }<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">BANNER<\/h1>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Host &#8221; \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2551 Intune Compliance Drift Detection \u2551&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2551 move2modern.co.uk \u2551&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d&#8221; -ForegroundColor Cyan<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 1 \u2014 MODULE CHECK<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 1 \u2014 Checking modules&#8221;<\/p>\n\n\n\n<p>foreach ($Mod in @(&#8216;Microsoft.Graph.Authentication&#8217;, &#8216;Microsoft.Graph.DeviceManagement&#8217;)) {<br>if (Get-Module -ListAvailable -Name $Mod) {<br>Write-Ok &#8220;$Mod is installed&#8221;<br>} else {<br>Write-Warn &#8220;$Mod not found \u2014 installing\u2026&#8221;<br>try {<br>Install-Module $Mod -Scope CurrentUser -Force -ErrorAction Stop<br>Write-Ok &#8220;$Mod installed successfully&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Could not install $Mod : $_&#8221; -ForegroundColor Red<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<br>}<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 2 \u2014 CONNECT<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 2 \u2014 Connecting to Microsoft Graph&#8221;<br>Write-Info &#8220;A browser sign-in window will open. Sign in with an account that has&#8221;<br>Write-Info &#8220;the DeviceManagementManagedDevices.Read.All permission.&#8221;<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>try {<br>Connect-MgGraph -Scopes &#8216;DeviceManagementManagedDevices.Read.All&#8217; `<br>-NoWelcome -ErrorAction Stop<br>$ctx = Get-MgContext<br>Write-Ok &#8220;Connected as : $($ctx.Account)&#8221;<br>Write-Info &#8220;Tenant : $($ctx.TenantId)&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Connection failed: $_&#8221; -ForegroundColor Red<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 3 \u2014 RETRIEVE DEVICES<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 3 \u2014 Retrieving managed devices&#8221;<br>Write-Info &#8220;Using -All to ensure all pages are retrieved (large tenants may take a moment)\u2026&#8221;<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>try {<br>$Devices = Get-MgDeviceManagementManagedDevice -All -Property <code>Id, DeviceName, ComplianceState, LastSyncDateTime,<\/code><br>OperatingSystem, OSVersion, UserPrincipalName, EnrolledDateTime `<br>-ErrorAction Stop<br>Write-Ok &#8220;Total managed devices found: $($Devices.Count)&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Failed to retrieve devices: $_&#8221; -ForegroundColor Red<br>Disconnect-MgGraph<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 4 \u2014 FILTER AND BUILD REPORT<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 4 \u2014 Identifying drift&#8221;<\/p>\n\n\n\n<p>$DriftedDevices = $Devices | Where-Object {<br>$_.ComplianceState -notin @(&#8216;compliant&#8217;, &#8216;notApplicable&#8217;)<br>}<\/p>\n\n\n\n<p>$Report = $DriftedDevices | Select-Object @(<br>@{ N = &#8216;DeviceName&#8217;; E = { $_.DeviceName } }<br>@{ N = &#8216;UPN&#8217;; E = { $_.UserPrincipalName } }<br>@{ N = &#8216;State&#8217;; E = { $_.ComplianceState } }<br>@{ N = &#8216;OS&#8217;; E = { $_.OperatingSystem } }<br>@{ N = &#8216;OSVersion&#8217;; E = { $_.OSVersion } }<br>@{ N = &#8216;LastSync&#8217;; E = { $_.LastSyncDateTime } }<br>@{ N = &#8216;DaysSinceSync&#8217;; E = { <a href=\"(Get-Date\">int<\/a> &#8211; [datetime]$_.LastSyncDateTime).TotalDays } }<br>@{ N = &#8216;EnrolledDate&#8217;; E = { $_.EnrolledDateTime } }<br>)<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 5 \u2014 CONSOLE OUTPUT<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 5 \u2014 Results&#8221;<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>if ($Report.Count -eq 0) {<br>Write-Host &#8221; \u2705 No drifted devices found. All managed devices are compliant or not applicable.&#8221; `<br>-ForegroundColor Green<br>} else {<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Summary line\nWrite-Host (\"  \u26a0  {0} drifted device(s) out of {1} total  ({2:0.0}%)\" -f `\n    $Report.Count, $Devices.Count, (($Report.Count \/ $Devices.Count) * 100)) `\n    -ForegroundColor Yellow\nWrite-Host \"\"\n\n# Column widths \u2014 dynamic, based on actual data, capped for readability\n$colW = @{\n    DeviceName = &#91;Math]::Min(30, ($Report | Measure-Object { $_.DeviceName.Length } -Maximum).Maximum + 2)\n    UPN        = &#91;Math]::Min(36, ($Report | Measure-Object { $_.UPN.Length }        -Maximum).Maximum + 2)\n    State      = 18\n    OS         = 10\n    DaysSync   = 12\n}\n\n# Header row\n$header = \"  {0,-$($colW.DeviceName)}{1,-$($colW.UPN)}{2,-$($colW.State)}{3,-$($colW.OS)}{4,-$($colW.DaysSync)}\" `\n    -f 'Device Name', 'User', 'State', 'OS', 'Days Since Sync'\n$divider = \"  \" + (\"\u2500\" * ($colW.DeviceName + $colW.UPN + $colW.State + $colW.OS + $colW.DaysSync))\n\nWrite-Host $header    -ForegroundColor White\nWrite-Host $divider   -ForegroundColor DarkGray\n\n# Data rows \u2014 colour by state\nforeach ($Device in $Report | Sort-Object State, DeviceName) {\n    $stateColor = switch ($Device.State) {\n        'noncompliant'  { 'Red'    }\n        'error'         { 'Red'    }\n        'unknown'       { 'Yellow' }\n        'inGracePeriod' { 'Cyan'   }\n        default         { 'White'  }\n    }\n\n    $row = \"  {0,-$($colW.DeviceName)}{1,-$($colW.UPN)}{2,-$($colW.State)}{3,-$($colW.OS)}{4,-$($colW.DaysSync)}\" `\n        -f `\n        ($Device.DeviceName   | Select-Object -First 1),\n        ($Device.UPN          | Select-Object -First 1),\n        $Device.State,\n        $Device.OS,\n        $Device.DaysSinceSync\n\n    Write-Host $row -ForegroundColor $stateColor\n}\n\nWrite-Host $divider -ForegroundColor DarkGray\nWrite-Host \"\"\n\n# State breakdown\nWrite-Host \"  Breakdown by state:\" -ForegroundColor White\n$Report | Group-Object State | Sort-Object Count -Descending | ForEach-Object {\n    Write-Host (\"    {0,-20} {1} device(s)\" -f $_.Name, $_.Count) -ForegroundColor Gray\n}<\/code><\/pre>\n\n\n\n<p>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:0px\">STEP 6 \u2014 SAVE CSV<\/h1>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Header &#8220;Step 6 \u2014 Saving report&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">Create output folder if it doesn&#8217;t exist<\/h1>\n\n\n\n<p>if (-not (Test-Path $OutputFolder)) {<br>New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null<br>Write-Ok &#8220;Created folder: $OutputFolder&#8221;<br>} else {<br>Write-Info &#8220;Output folder: $OutputFolder&#8221;<br>}<\/p>\n\n\n\n<p>if ($Report.Count -eq 0) {<br>Write-Info &#8220;No drifted devices to save.&#8221;<br>} else {<br>try {<br>$Report | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8 -ErrorAction Stop<br>Write-Ok &#8220;CSV saved to: $OutputFile&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Could not save CSV: $_&#8221; -ForegroundColor Red<br>}<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">DONE<\/h1>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Host &#8221; \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500&#8221; -ForegroundColor DarkGray<br>Write-Host &#8221; Done \u00b7 move2modern.co.uk&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500&#8221; -ForegroundColor DarkGray<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>Disconnect-MgGraph<br>Read-Host &#8221; Press Enter to close&#8221;<\/p>\n<\/div>\n<\/div>\n<\/div>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Drilling Deeper: Per-Policy States<\/h3>\n\n\n\n<p style=\"font-size:16px\">Device-level compliance state tells you that a device has drifted, but not which policy or setting caused it. For that, query the <em>deviceConfigurationStates <\/em>endpoint per device<\/p>\n\n\n\n<div role=\"dialog\" aria-modal=\"true\" class=\"wp-block-cloudcatch-light-modal-block__wrapper\" data-modal-id=\"z0hgqHLWppc\"><div class=\"wp-block-cloudcatch-light-modal-block\"><div class=\"wp-block-cloudcatch-light-modal-block__content\"><\/div><button class=\"wp-block-cloudcatch-light-modal-block__close\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M24 1.2 22.8 0 12 10.8 1.2 0 0 1.2 10.8 12 0 22.8 1.2 24 12 13.2 22.8 24l1.2-1.2L13.2 12 24 1.2z\"><\/path><\/svg><\/button><\/div><\/div>\n\n\n\n<div role=\"dialog\" aria-modal=\"true\" class=\"wp-block-cloudcatch-light-modal-block__wrapper\" data-modal-id=\"AViJNX5SGdn\"><div class=\"wp-block-cloudcatch-light-modal-block\"><div class=\"wp-block-cloudcatch-light-modal-block__content\"><\/div><button class=\"wp-block-cloudcatch-light-modal-block__close\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M24 1.2 22.8 0 12 10.8 1.2 0 0 1.2 10.8 12 0 22.8 1.2 24 12 13.2 22.8 24l1.2-1.2L13.2 12 24 1.2z\"><\/path><\/svg><\/button><\/div><\/div>\n\n\n\n<div role=\"dialog\" aria-modal=\"true\" class=\"wp-block-cloudcatch-light-modal-block__wrapper\" data-modal-id=\"oVBOwNM4tqr\"><div class=\"wp-block-cloudcatch-light-modal-block\"><div class=\"wp-block-cloudcatch-light-modal-block__content\"><\/div><button class=\"wp-block-cloudcatch-light-modal-block__close\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M24 1.2 22.8 0 12 10.8 1.2 0 0 1.2 10.8 12 0 22.8 1.2 24 12 13.2 22.8 24l1.2-1.2L13.2 12 24 1.2z\"><\/path><\/svg><\/button><\/div><\/div>\n\n\n\n<div class=\"wp-block-file\"><a id=\"wp-block-file--media-75070669-666f-43d8-aec0-ccba08eb6f42\" href=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Drift-detection-Script-2-1.txt\">Drift detection Script 2<\/a><a href=\"https:\/\/move2modern.uk\/wp-content\/uploads\/2026\/03\/Drift-detection-Script-2-1.txt\" class=\"wp-block-file__button wp-element-button\" download aria-describedby=\"wp-block-file--media-75070669-666f-43d8-aec0-ccba08eb6f42\">Download<\/a><\/div>\n\n\n\n<div data-wp-context=\"{ &quot;autoclose&quot;: false, &quot;accordionItems&quot;: [] }\" data-wp-interactive=\"core\/accordion\" role=\"group\" class=\"wp-block-accordion is-layout-flow wp-block-accordion-is-layout-flow\">\n<div data-wp-class--is-open=\"state.isOpen\" data-wp-context=\"{ &quot;id&quot;: &quot;accordion-item-2&quot;, &quot;openByDefault&quot;: false }\" data-wp-init=\"callbacks.initAccordionItems\" data-wp-on-window--hashchange=\"callbacks.hashChange\" class=\"wp-block-accordion-item has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background is-layout-flow wp-block-accordion-item-is-layout-flow\">\n<h3 class=\"wp-block-accordion-heading has-white-color has-midnight-gradient-background has-text-color has-background has-link-color wp-elements-aef07b784fce913273c9afddd7a1cdb8\" style=\"font-size:20px\"><button aria-expanded=\"false\" aria-controls=\"accordion-item-2-panel\" data-wp-bind--aria-expanded=\"state.isOpen\" data-wp-on--click=\"actions.toggle\" data-wp-on--keydown=\"actions.handleKeyDown\" id=\"accordion-item-2\" type=\"button\" class=\"wp-block-accordion-heading__toggle\"><span class=\"wp-block-accordion-heading__toggle-title\">PowerShell Script 2<\/span><span class=\"wp-block-accordion-heading__toggle-icon\" aria-hidden=\"true\">+<\/span><\/button><\/h3>\n\n\n\n<div inert aria-labelledby=\"accordion-item-2\" data-wp-bind--inert=\"!state.isOpen\" id=\"accordion-item-2-panel\" role=\"region\" class=\"wp-block-accordion-panel is-layout-flow wp-block-accordion-panel-is-layout-flow\">\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">Requires -Version 7.0<\/h1>\n\n\n\n<p>&lt;#<br>.SYNOPSIS<br>Intune Per-Device Policy Drift Investigation<br>move2modern.co.uk \u2014 Part 1 companion script<\/p>\n\n\n\n<p>.DESCRIPTION<br>Run this after Get-IntuneComplianceDrift.ps1 has identified drifted devices.<br>Takes a Device ID (from the drift report CSV) and shows exactly which<br>policies are causing the problem on that specific device \u2014 including<br>conflict counts, which are the most common and painful real-world cause.<\/p>\n\n\n\n<p>.HOW TO USE<br>Option A \u2014 use the DeviceId from the DriftReport CSV (most reliable):<br>.\\Get-DevicePolicyDrift.ps1 -DeviceId &#8220;xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&#8221;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Option B \u2014 use the device name as shown in Intune:\n    .\\Get-DevicePolicyDrift.ps1 -DeviceName \"DESKTOP-ABC123\"\n\nOption C \u2014 run the script with no parameters and it will prompt you.\n    Paste either the DeviceId or DeviceName when asked.\n    The script will detect which you've entered automatically.<\/code><\/pre>\n\n\n\n<h1 class=\"wp-block-heading\">&gt;<\/h1>\n\n\n\n<p>param(<br>[string]$DeviceId,<br>[string]$DeviceName<br>)<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">HELPERS<\/h1>\n\n\n\n<p>function Write-Header ($Text) {<br>Write-Host &#8220;&#8221;<br>Write-Host &#8221; $Text&#8221; -ForegroundColor Cyan<br>Write-Host (&#8221; &#8221; + (&#8220;\u2500&#8221; * ($Text.Length))) -ForegroundColor DarkGray<br>}<\/p>\n\n\n\n<p>function Write-Ok ($Msg) { Write-Host &#8221; [OK] $Msg&#8221; -ForegroundColor Green }<br>function Write-Warn ($Msg) { Write-Host &#8221; [!!] $Msg&#8221; -ForegroundColor Yellow }<br>function Write-Info ($Msg) { Write-Host &#8221; $Msg&#8221; -ForegroundColor Gray }<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">BANNER<\/h1>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Host &#8221; \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2551 Intune Per-Device Policy Drift Investigation \u2551&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2551 move2modern.co.uk \u2551&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d&#8221; -ForegroundColor Cyan<br>Write-Host &#8220;&#8221;<br>Write-Info &#8220;Run Get-IntuneComplianceDrift.ps1 first to identify drifted devices.&#8221;<br>Write-Info &#8220;Then use the Device ID from that report&#8217;s CSV to investigate here.&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 1 \u2014 MODULE CHECK<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 1 \u2014 Checking modules&#8221;<\/p>\n\n\n\n<p>foreach ($Mod in @(&#8216;Microsoft.Graph.Authentication&#8217;, &#8216;Microsoft.Graph.DeviceManagement&#8217;)) {<br>if (Get-Module -ListAvailable -Name $Mod) {<br>Write-Ok &#8220;$Mod is installed&#8221;<br>} else {<br>Write-Warn &#8220;$Mod not found \u2014 installing\u2026&#8221;<br>try {<br>Install-Module $Mod -Scope CurrentUser -Force -ErrorAction Stop<br>Write-Ok &#8220;$Mod installed successfully&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Could not install $Mod : $_&#8221; -ForegroundColor Red<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<br>}<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 2 \u2014 CONNECT<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 2 \u2014 Connecting to Microsoft Graph&#8221;<\/p>\n\n\n\n<p>try {<br>Connect-MgGraph -Scopes &#8216;DeviceManagementManagedDevices.Read.All&#8217; `<br>-NoWelcome -ErrorAction Stop<br>$ctx = Get-MgContext<br>Write-Ok &#8220;Connected as : $($ctx.Account)&#8221;<br>Write-Info &#8220;Tenant : $($ctx.TenantId)&#8221;<br>} catch {<br>Write-Host &#8221; [XX] Connection failed: $_&#8221; -ForegroundColor Red<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 3 \u2014 IDENTIFY DEVICE<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 3 \u2014 Device to investigate&#8221;<br>Write-Host &#8220;&#8221;<br>Write-Info &#8220;You can provide either the DeviceId (from the DriftReport CSV)&#8221;<br>Write-Info &#8220;or the DeviceName (exactly as it appears in Intune).&#8221;<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">Prompt if neither was passed as a parameter<\/h1>\n\n\n\n<p>if (-not $DeviceId -and -not $DeviceName) {<br>$Input = Read-Host &#8221; Device Name or Device ID&#8221;<br>$Input = $Input.Trim()<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Heuristic: GUIDs are 36 chars with hyphens \u2014 treat everything else as a name\nif ($Input -match '^&#91;0-9a-fA-F]{8}-&#91;0-9a-fA-F]{4}-&#91;0-9a-fA-F]{4}-&#91;0-9a-fA-F]{4}-&#91;0-9a-fA-F]{12}$') {\n    $DeviceId = $Input\n} else {\n    $DeviceName = $Input\n}<\/code><\/pre>\n\n\n\n<p>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">If we have a name but no ID, look it up via Graph API directly<\/h1>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">(using Invoke-MgGraphRequest avoids the empty-property issue with SDK filters)<\/h1>\n\n\n\n<p>if ($DeviceName -and -not $DeviceId) {<br>Write-Info &#8220;Looking up &#8216;$DeviceName&#8217;\u2026&#8221;<br>try {<br>$EncodedName = [Uri]::EscapeDataString($DeviceName)<br>$Response = Invoke-MgGraphRequest <code>-Uri \"https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices?<\/code>$filter=deviceName eq &#8216;$EncodedName&#8217;&amp;<code>$select=id,deviceName,userPrincipalName,complianceState,operatingSystem\"<\/code><br>-Method GET -ErrorAction Stop<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>    $Lookup = $Response.value\n\n    if ($Lookup.Count -eq 0) {\n        Write-Host \"  &#91;XX] No device found with name '$DeviceName'.\" -ForegroundColor Red\n        Write-Info \"Check the name matches exactly as shown in Intune (case-sensitive).\"\n        Write-Info \"Alternatively, use the DeviceId from the DriftReport CSV.\"\n        Disconnect-MgGraph\n        Read-Host \"  Press Enter to close\"\n        exit 1\n    }\n\n    if ($Lookup.Count -gt 1) {\n        Write-Warn \"Multiple devices found with that name \u2014 picking the first match.\"\n        Write-Info \"If this is wrong, re-run using -DeviceId from the CSV instead.\"\n    }\n\n    $Found    = $Lookup&#91;0]\n    $DeviceId = $Found.id\n    $Device   = &#91;PSCustomObject]@{\n        DeviceName        = $Found.deviceName\n        UserPrincipalName = $Found.userPrincipalName\n        ComplianceState   = $Found.complianceState\n        OperatingSystem   = $Found.operatingSystem\n    }\n    Write-Ok \"Device resolved: $($Device.DeviceName) \u2192 $DeviceId\"\n\n} catch {\n    Write-Host \"  &#91;XX] Name lookup failed: $_\" -ForegroundColor Red\n    Disconnect-MgGraph\n    Read-Host \"  Press Enter to close\"\n    exit 1\n}<\/code><\/pre>\n\n\n\n<p>} else {<br># We have an ID \u2014 fetch device details via Graph API directly<br>$DeviceId = $DeviceId.Trim()<br>try {<br>$Found = Invoke-MgGraphRequest <code>-Uri \"https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices\/$DeviceId<\/code>?<code>$select=id,deviceName,userPrincipalName,complianceState,operatingSystem\"<\/code><br>-Method GET -ErrorAction Stop<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>    $Device = &#91;PSCustomObject]@{\n        DeviceName        = $Found.deviceName\n        UserPrincipalName = $Found.userPrincipalName\n        ComplianceState   = $Found.complianceState\n        OperatingSystem   = $Found.operatingSystem\n    }\n} catch {\n    Write-Host \"  &#91;XX] Device ID not found. Check the ID is correct and belongs to this tenant.\" -ForegroundColor Red\n    Disconnect-MgGraph\n    Read-Host \"  Press Enter to close\"\n    exit 1\n}<\/code><\/pre>\n\n\n\n<p>}<\/p>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Ok &#8220;Device found : $($Device.DeviceName)&#8221;<br>Write-Info &#8220;User : $($Device.UserPrincipalName)&#8221;<br>Write-Info &#8220;OS : $($Device.OperatingSystem)&#8221;<br>Write-Info &#8220;Overall state : $($Device.ComplianceState)&#8221;<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 4 \u2014 RETRIEVE POLICY STATES<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 4 \u2014 Retrieving policy states&#8221;<\/p>\n\n\n\n<p>try {<br># Use Invoke-MgGraphRequest directly \u2014 the SDK cmdlet for this endpoint<br># is not available in all Microsoft.Graph module configurations<br>$Response = Invoke-MgGraphRequest <code>-Uri \"https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices\/$DeviceId\/deviceConfigurationStates\"<\/code><br>-Method GET -ErrorAction Stop<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$AllStates = $Response.value\nWrite-Ok \"Total policies assigned to this device: $($AllStates.Count)\"<\/code><\/pre>\n\n\n\n<p>} catch {<br>$ErrDetail = $_.ErrorDetails.Message<br>if (-not $ErrDetail) { $ErrDetail = $_.Exception.Message }<br>Write-Host &#8221; [XX] Failed to retrieve policy states: $ErrDetail&#8221; -ForegroundColor Red<br>Disconnect-MgGraph<br>Read-Host &#8221; Press Enter to close&#8221;<br>exit 1<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 5 \u2014 DISPLAY RESULTS<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 5 \u2014 Policy drift results for $($Device.DeviceName)&#8221;<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>$ProblemStates = $AllStates | Where-Object {<br>$_.state -notin @(&#8216;compliant&#8217;, &#8216;notApplicable&#8217;)<br>}<\/p>\n\n\n\n<p>if ($ProblemStates.Count -eq 0) {<br>Write-Host &#8221; \u2705 No policy-level drift found on this device.&#8221; -ForegroundColor Green<br>Write-Info &#8220;The device may show as non-compliant due to a compliance policy,&#8221;<br>Write-Info &#8220;not a configuration profile. Check the compliance policy status in Intune.&#8221;<br>} else {<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Write-Host (\"  \u26a0  {0} policy\/policies with issues found  ({1} total assigned)\" -f `\n    $ProblemStates.Count, $AllStates.Count) -ForegroundColor Yellow\nWrite-Host \"\"\n\n# \u2500\u2500 Conflicts first \u2014 these are the important ones \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n$Conflicts = $ProblemStates | Where-Object { $_.conflictCount -gt 0 }\nif ($Conflicts.Count -gt 0) {\n    Write-Host \"  CONFLICTS  (two or more policies fighting over the same setting)\" `\n        -ForegroundColor Red\n    Write-Host \"  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\" `\n        -ForegroundColor DarkGray\n    foreach ($Item in $Conflicts | Sort-Object conflictCount -Descending) {\n        Write-Host (\"  \u274c  {0}\" -f $Item.displayName) -ForegroundColor Red\n        Write-Host (\"      State: {0}   Conflicts: {1}   Errors: {2}\" -f `\n            $Item.state, $Item.conflictCount, $Item.errorCount) -ForegroundColor DarkRed\n    }\n    Write-Host \"\"\n    Write-Warn \"Conflicts are not fixed by a device sync \u2014 they need a policy review.\"\n    Write-Info \"Go to Intune &gt; Devices &gt; &#91;this device] &gt; Configuration to see which\"\n    Write-Info \"policies overlap and which setting is being contested.\"\n    Write-Host \"\"\n}\n\n# \u2500\u2500 Errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n$Errors = $ProblemStates | Where-Object { $_.state -eq 'error' -and $_.conflictCount -eq 0 }\nif ($Errors.Count -gt 0) {\n    Write-Host \"  ERRORS  (policy failed to apply)\" -ForegroundColor Red\n    Write-Host \"  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\" `\n        -ForegroundColor DarkGray\n    foreach ($Item in $Errors) {\n        Write-Host (\"  \u274c  {0}\" -f $Item.displayName) -ForegroundColor Red\n        Write-Host (\"      State: {0}   Errors: {1}\" -f $Item.state, $Item.errorCount) `\n            -ForegroundColor DarkRed\n    }\n    Write-Host \"\"\n}\n\n# \u2500\u2500 Non-compliant (no conflict, no error) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n$NonCompliant = $ProblemStates | Where-Object {\n    $_.state -eq 'nonCompliant' -and $_.conflictCount -eq 0\n}\nif ($NonCompliant.Count -gt 0) {\n    Write-Host \"  NON-COMPLIANT  (settings don't match policy requirements)\" `\n        -ForegroundColor Yellow\n    Write-Host \"  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\" `\n        -ForegroundColor DarkGray\n    foreach ($Item in $NonCompliant) {\n        Write-Host (\"  \u26a0  {0}\" -f $Item.displayName) -ForegroundColor Yellow\n        Write-Host (\"      State: {0}   Settings checked: {1}\" -f `\n            $Item.state, $Item.settingCount) -ForegroundColor DarkYellow\n    }\n    Write-Host \"\"\n}\n\n# \u2500\u2500 Everything else (unknown, inGracePeriod, etc.) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n$Other = $ProblemStates | Where-Object {\n    $_.state -notin @('nonCompliant', 'error') -and $_.conflictCount -eq 0\n}\nif ($Other.Count -gt 0) {\n    Write-Host \"  OTHER STATES\" -ForegroundColor Gray\n    Write-Host \"  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\" `\n        -ForegroundColor DarkGray\n    foreach ($Item in $Other) {\n        Write-Host (\"  \u2022  {0}  &#91;{1}]\" -f $Item.displayName, $Item.state) -ForegroundColor Gray\n    }\n    Write-Host \"\"\n}<\/code><\/pre>\n\n\n\n<p>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">STEP 6 \u2014 SAVE RESULTS<\/h1>\n\n\n\n<p>Write-Header &#8220;Step 6 \u2014 Saving results&#8221;<\/p>\n\n\n\n<p>$OutputFolder = Join-Path ([Environment]::GetFolderPath(&#8216;MyDocuments&#8217;)) &#8216;DriftBaselines&#8217;<br>$OutputFile = Join-Path $OutputFolder &#8220;PolicyDrift_$($Device.DeviceName -replace &#8216;[^a-zA-Z0-9]&#8217;,&#8217;<em>&#8216;)<\/em>$(Get-Date -Format &#8216;yyyyMMdd_HHmmss&#8217;).csv&#8221;<\/p>\n\n\n\n<p>if (-not (Test-Path $OutputFolder)) {<br>New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null<br>}<\/p>\n\n\n\n<p>if ($ProblemStates.Count -gt 0) {<br>$ProblemStates | Select-Object `<br>@{ N = &#8216;PolicyName&#8217;; E = { $_.displayName } },<br>@{ N = &#8216;State&#8217;; E = { $_.state } },<br>@{ N = &#8216;ConflictCount&#8217;; E = { $_.conflictCount } },<br>@{ N = &#8216;ErrorCount&#8217;; E = { $_.errorCount } },<br>@{ N = &#8216;SettingCount&#8217;; E = { $_.settingCount } } |<br>Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8<br>Write-Ok &#8220;Results saved to: $OutputFile&#8221;<br>} else {<br>Write-Info &#8220;Nothing to save \u2014 no policy drift detected.&#8221;<br>}<\/p>\n\n\n\n<h1 class=\"wp-block-heading\" style=\"font-size:20px\">DONE<\/h1>\n\n\n\n<p>Write-Host &#8220;&#8221;<br>Write-Host &#8221; \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500&#8221; -ForegroundColor DarkGray<br>Write-Host &#8221; Done \u00b7 move2modern.co.uk&#8221; -ForegroundColor Cyan<br>Write-Host &#8221; \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500&#8221; -ForegroundColor DarkGray<br>Write-Host &#8220;&#8221;<\/p>\n\n\n\n<p>Disconnect-MgGraph<br>Read-Host &#8221; Press Enter to close&#8221;<\/p>\n<\/div>\n<\/div>\n<\/div>\n\n\n\n<p style=\"font-size:16px\">The <em>ConflictCount <\/em>property is particularly useful \u2014 a conflict means two or more policies are fighting over the same setting and the device is caught in the middle. This is one of the most common real-world drift causes and one of the most annoying to diagnose manually in the portal.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Pros and Cons<\/h3>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\" style=\"font-size:13px\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>\u2705&nbsp; Pros<\/strong><\/td><td><strong>\u274c&nbsp; Cons<\/strong><\/td><\/tr><tr><td>No additional licensing required<\/td><td>Requires ongoing maintenance as Graph API evolves<\/td><\/tr><tr><td>Full control over logic and output format<\/td><td>No built-in alerting \u2014 you wire that up yourself<\/td><\/tr><tr><td>Can be run on-demand or scheduled via Azure Automation<\/td><td>Each run is stateless unless you build logging<\/td><\/tr><tr><td>Portable \u2014 runs from any machine with internet access<\/td><td>Client secret management is a security responsibility<\/td><\/tr><tr><td>Great for one-off audits and ad-hoc troubleshooting<\/td><td>Doesn&#8217;t scale well for real-time detection across thousands of devices<\/td><\/tr><tr><td>Easily customisable for your specific environment<\/td><td>&nbsp;<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 3&nbsp; \u00b7&nbsp; <strong>Approach 2: Power Automate for Automated Workflows<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Letting the Robots Handle It<\/h2>\n\n\n\n<p style=\"font-size:16px\">Power Automate elevates drift detection from a script you remember to run into a workflow that runs itself. But its value goes well beyond simply scheduling a compliance poll. With the HTTP connector and Graph API, you can build a complete configuration monitoring pipeline: extract your entire policy configuration as a JSON baseline, store it, compare it on a schedule, and get notified the moment something changes \u2014 covering compliance policies, configuration profiles, and Conditional Access policies in a single automated flow.<\/p>\n\n\n\n<p style=\"font-size:16px\">This section covers two complementary flows: a device-level compliance drift checker, and the more powerful tenant-configuration baseline comparison workflow.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:16px\">What You&#8217;ll Need<\/h3>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Requirement<\/strong><\/td><td><strong>Detail<\/strong><\/td><\/tr><tr><td>Licensing<\/td><td>Power Automate is included with most M365 plans. The HTTP connector (needed to call Graph API directly) requires Power Automate Per User\/Per Flow or M365 E3\/E5.<\/td><\/tr><tr><td>Connectors<\/td><td>HTTP (premium), Office 365 Outlook, Microsoft Teams, SharePoint.<\/td><\/tr><tr><td>App Registration<\/td><td>Same as the PowerShell approach \u2014 Entra App Registration with Graph API permissions. Credentials stored as encrypted values in flow actions.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>SharePoint Site<\/strong><\/td><td>A SharePoint document library to store baseline JSON files and a SharePoint List for drift log entries.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:16px\">A Scheduled Drift Detection Flow<\/h2>\n\n\n\n<p style=\"font-size:16px\">Here&#8217;s the logical structure for a scheduled drift checker. The key stages are:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li style=\"font-size:16px\"><strong>Trigger: <\/strong>Recurrence \u2014 daily at 06:00 UTC (before the helpdesk opens)<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: HTTP POST <\/strong>to https:\/\/login.microsoftonline.com\/{tenantId}\/oauth2\/v2.0\/token to get a bearer token<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: HTTP GET <\/strong>to the Graph endpoint below, server-side filtered to non-compliant devices only<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Condition: <\/strong>if the array is empty, send an \u2018all clear\u2019 notification and exit; if not, proceed<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Apply to Each: <\/strong>loop through drifted devices, create a SharePoint list item for each with device name, state, last sync, and timestamp<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: Post Teams Adaptive Card <\/strong>to your IT ops channel with a count summary and list of affected devices<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Condition: <\/strong>if drift count exceeds a threshold (e.g. 10), send an escalation email to your IT manager<\/li>\n<\/ol>\n\n\n\n<p style=\"font-size:16px\">The OData filter in step 3 does the heavy lifting \u2014 rather than downloading all devices and filtering locally, we ask Graph to do it server-side:<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>\/\/ URI for HTTP GET action \u2014 filters non-compliant devices server-side <br>\/\/ URI-encode the filter string in production &nbsp; <br><br>https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices ?$filter=complianceState ne &#8216;compliant&#8217; and complianceState ne &#8216;notApplicable&#8217; &amp;$select=id,deviceName,complianceState,lastSyncDateTime,userPrincipalName,operatingSystem,osVersion &amp;$orderby=lastSyncDateTime asc &amp;$top=999<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure class=\"wp-block-table is-style-regular\" style=\"font-size:13px\"><table class=\"has-black-color has-luminous-vivid-amber-to-luminous-vivid-orange-gradient-background has-text-color has-background has-link-color has-fixed-layout\"><tbody><tr><td><strong>\u26a0<\/strong> <strong>OData filter support is inconsistent<\/strong> <br>Not all Intune Graph endpoint properties support OData filtering. The complianceState property does. If you get a 400 Bad Request, try removing the filter and doing client-side filtering \u2014 it&#8217;s slower but reliable.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Flow B: Tenant Configuration Baseline Capture<\/h2>\n\n\n\n<p style=\"font-size:16px\">The flow makes three Graph API calls to capture the full configuration picture: compliance policies with their assignment groups, configuration profiles with assignments, and Conditional Access policies. Each response is serialised to JSON and stored as a versioned file in SharePoint:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li style=\"font-size:16px\"><strong>Trigger: <\/strong>Manual trigger (Button) \u2014 run this deliberately when you want to establish or update a baseline<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: HTTP GET <\/strong>to fetch all compliance policies with assignments:<\/li>\n<\/ol>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>\/\/ Step 2a: Compliance policies with assignments<\/td><\/tr><tr><td>https:\/\/graph.microsoft.com\/beta\/deviceManagement\/deviceCompliancePolicies<\/td><\/tr><tr><td>&nbsp; ?$expand=assignments<\/td><\/tr><tr><td>&nbsp; &amp;$select=id,displayName,lastModifiedDateTime,assignments<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ Step 2b: Configuration profiles with assignments<\/td><\/tr><tr><td>https:\/\/graph.microsoft.com\/beta\/deviceManagement\/deviceConfigurations<\/td><\/tr><tr><td>&nbsp; ?$expand=assignments<\/td><\/tr><tr><td>&nbsp; &amp;$select=id,displayName,lastModifiedDateTime,assignments<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ Step 2c: Settings Catalog policies (newer framework)<\/td><\/tr><tr><td>https:\/\/graph.microsoft.com\/beta\/deviceManagement\/configurationPolicies<\/td><\/tr><tr><td>&nbsp; ?$expand=assignments<\/td><\/tr><tr><td>&nbsp; &amp;$select=id,name,lastModifiedDateTime,assignments<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ Step 2d: Conditional Access policies<\/td><\/tr><tr><td>https:\/\/graph.microsoft.com\/v1.0\/identity\/conditionalAccess\/policies<\/td><\/tr><tr><td>&nbsp; ?$select=id,displayName,state,conditions,grantControls,sessionControls,modifiedDateTime<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\"><strong>Action: Compose <\/strong>to build a single JSON object combining all four responses with a capturedAt timestamp:<\/li>\n<\/ul>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>\/\/ Compose action expression to build baseline JSON<\/td><\/tr><tr><td>{<\/td><\/tr><tr><td>&nbsp; &#8220;capturedAt&#8221;: &#8220;@{utcNow()}&#8221;,<\/td><\/tr><tr><td>&nbsp; &#8220;capturedBy&#8221;: &#8220;@{workflow().run.name}&#8221;,<\/td><\/tr><tr><td>&nbsp; &#8220;compliancePolicies&#8221;: @{body(&#8216;Get_Compliance_Policies&#8217;)[&#8216;value&#8217;]},<\/td><\/tr><tr><td>&nbsp; &#8220;configProfiles&#8221;: @{body(&#8216;Get_Config_Profiles&#8217;)[&#8216;value&#8217;]},<\/td><\/tr><tr><td>&nbsp; &#8220;catalogPolicies&#8221;: @{body(&#8216;Get_Catalog_Policies&#8217;)[&#8216;value&#8217;]},<\/td><\/tr><tr><td>&nbsp; &#8220;conditionalAccessPolicies&#8221;: @{body(&#8216;Get_CA_Policies&#8217;)[&#8216;value&#8217;]}<\/td><\/tr><tr><td>}<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\"><strong>Action: SharePoint Create File<\/strong> \u2014 save to your baselines document library as baseline_YYYYMMDD_HHMMSS.json. Use the dynamic timestamp in the filename to version each capture.<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: Post Teams message<\/strong> confirming the baseline was captured, with counts of each policy type included.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Flow C: Scheduled Baseline Comparison (Drift Detection)<\/h2>\n\n\n\n<p style=\"font-size:16px\">This is where the value compounds. This scheduled flow runs daily, captures a fresh snapshot of your current tenant configuration, then compares it against your stored baseline JSON. Any differences \u2014 a deleted profile, a changed CA policy, a modified assignment group \u2014 surface as drift records:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\"><strong>Trigger: <\/strong>Recurrence \u2014 daily at 07:00 UTC<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Actions: <\/strong>Same three HTTP GET calls as Flow B to capture current state<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: SharePoint Get File Content<\/strong> \u2014 retrieve your most recent baseline JSON from the SharePoint library<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Action: Parse JSON<\/strong> on both the baseline and current snapshot using the same schema<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Apply to Each (compliance policies): <\/strong>compare each policy in the baseline against current. Flag if: policy is missing from current, assignment groups have changed, or lastModifiedDateTime has changed since baseline<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Apply to Each (CA policies): <\/strong>compare each CA policy. Flag if: policy was deleted, state changed (enabled \u2192 disabled or vice versa), conditions changed, or grant controls modified<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Condition: <\/strong>if any drift items were found, create a SharePoint list entry per drift item and post a Teams Adaptive Card detailing what changed<\/li>\n\n\n\n<li style=\"font-size:16px\"><strong>Condition: <\/strong>if CA policy drift was detected, send a priority email \u2014 CA changes have immediate security implications<\/li>\n<\/ul>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>\/\/ Expression to detect assignment changes between baseline and current<\/td><\/tr><tr><td>\/\/ Use in a Condition action comparing the two JSON objects<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ In Compose &#8211; extract group IDs from baseline policy assignments:<\/td><\/tr><tr><td>join(baseline_policy[&#8216;assignments&#8217;], &#8216;,&#8217;)<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ Compare with current (not-equals condition triggers drift flag):<\/td><\/tr><tr><td>join(current_policy[&#8216;assignments&#8217;], &#8216;,&#8217;)<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ For CA policies &#8211; check if state changed:<\/td><\/tr><tr><td>@equals(baseline_ca_policy[&#8216;state&#8217;], current_ca_policy[&#8216;state&#8217;])<\/td><\/tr><tr><td>&nbsp;<\/td><\/tr><tr><td>\/\/ For modification timestamp change (any property may have changed):<\/td><\/tr><tr><td>@not(equals(baseline_policy[&#8216;lastModifiedDateTime&#8217;], current_policy[&#8216;lastModifiedDateTime&#8217;]))<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-electric-grass-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\ud83d\udca1What this catches that device polling misses<\/strong> Flow A tells you which devices have drifted from their policies. Flows B and C tell you whether your policies themselves have changed. These are different problems. A tenant where a Conditional Access policy has been silently disabled will show all devices as compliant while being in a significantly worse security posture. BUT both levels of monitoring matter.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Adding Automated Remediation Triggers<\/h3>\n\n\n\n<p style=\"font-size:16px\">Power Automate can also initiate remediation \u2014 not just detect drift. The most straightforward action is triggering a device sync for devices showing compliance drift:<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>\/\/ Force sync on a specific managed device <br>\/\/ POST https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices\/{id}\/syncDevice &nbsp; <br>\/\/ In Power Automate HTTP action: <br>\/\/ Method: POST <br>\/\/ URI: https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices\/@{items(&#8216;Apply_to_each&#8217;)?[&#8216;id&#8217;]}\/syncDevice <br>\/\/ Headers: Authorization: Bearer @{body(&#8216;Get_Token&#8217;)?[&#8216;access_token&#8217;]} <br>\/\/ Body: {} (empty JSON object required)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-pale-ocean-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\u2139<\/strong> <strong>Sync vs. fix<\/strong> Triggering a sync tells the device to check in and re-apply pending policies. It doesn&#8217;t fix drift caused by a policy conflict \u2014 if two policies are fighting over the same setting, the sync will just confirm the problem. Knowing the difference matters before you automate anything.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p><strong>Pros and Cons<\/strong><\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>\u2705&nbsp; Pros<\/strong><\/td><td><strong>\u274c&nbsp; Cons<\/strong><\/td><\/tr><tr><td>No infrastructure to manage \u2014 fully cloud-hosted<\/td><td>HTTP connector requires premium licensing<\/td><\/tr><tr><td>Built-in scheduling, retry logic, and run history<\/td><td>Flow execution limits can bite in large tenants<\/td><\/tr><tr><td>Native integration with Teams, SharePoint, Outlook<\/td><td>Complex logic is harder to version-control vs. scripts<\/td><\/tr><tr><td>Low-code and accessible to admins without deep scripting<\/td><td>Debugging failed flows can be a painful experience<\/td><\/tr><tr><td>Can create automatic audit trails in SharePoint<\/td><td>Service account\/credential management is buried in flow config<\/td><\/tr><tr><td>Adaptive Cards in Teams give rich, actionable notifications<\/td><td>Limited ability to do complex data transformations without expressions<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<figure style=\"font-size:20px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 4&nbsp; \u00b7&nbsp; <strong>Approach 3: PowerShell + Azure Automation (Best of Both)<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Best of Both Worlds<\/h2>\n\n\n\n<p style=\"font-size:16px\">PowerShell is great at data collection and complex logic. Power Automate is great at scheduling, notifications, and workflow orchestration. They&#8217;re not in competition \u2014 they&#8217;re complements. Combining PowerShell runbooks in Azure Automation with Power Automate as the orchestration layer gives you raw capability plus operational convenience.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">The Architecture<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\">Azure Automation Account \u2014 hosts PowerShell runbooks that do the heavy lifting: Graph API queries, complex filtering, baseline comparisons, CSV generation. Managed Identity support means no client secret to rotate.<\/li>\n\n\n\n<li style=\"font-size:16px\">Power Automate \u2014 acts as the orchestrator and notification layer. Triggers the Automation runbook on a schedule, waits for completion, routes alerts based on results.<\/li>\n\n\n\n<li style=\"font-size:16px\">Azure Storage Account (optional) \u2014 Automation runbooks write CSV reports to blob storage; the Power Automate flow generates a SAS URL and includes it in the Teams notification for easy download.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Granting Graph Permissions to Azure Automation<\/h3>\n\n\n\n<p style=\"font-size:16px\">The step that trips most people up: you can&#8217;t toggle Graph permissions for a managed identity in the Entra portal. You have to use PowerShell:<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td># Run once to grant Graph permissions to your Automation Account Managed Identity <br># Replace $automationMIObjectId with the Object ID from the Automation Account Identity blade <br>&nbsp;<br>Connect-MgGraph -Scopes <br>&#8216;AppRoleAssignment.ReadWrite.All&#8217;,&#8217;Application.Read.All&#8217; &nbsp; <br><br>$automationMIObjectId = &#8216;&lt;Your-Automation-Account-MI-Object-ID&gt;&#8217; $graphSPN = Get-MgServicePrincipal -Filter &#8220;AppId eq &#8216;00000003-0000-0000-c000-000000000000&#8242;&#8221; &nbsp; <br><br>$permissions = @( &nbsp;&nbsp;&nbsp; &#8216;DeviceManagementManagedDevices.Read.All&#8217;, &nbsp;&nbsp;&nbsp; &#8216;DeviceManagementConfiguration.Read.All&#8217; ) &nbsp; <br><br>foreach ($perm in $permissions) { &nbsp;&nbsp;&nbsp; <br>$appRole = $graphSPN.AppRoles | Where-Object { &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <br>$_.Value -eq $perm -and $_.AllowedMemberTypes -contains &#8216;Application&#8217; &nbsp;&nbsp;&nbsp; <br>} &nbsp;&nbsp;&nbsp; <br>New-MgServicePrincipalAppRoleAssignment `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -ServicePrincipalId $automationMIObjectId `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -PrincipalId&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $automationMIObjectId `<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -ResourceId&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $graphSPN.Id `<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -AppRoleId&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $appRole.Id &nbsp;&nbsp;&nbsp; <br>Write-Host &#8220;Granted: $perm&#8221;<br> }<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-pale-ocean-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\u2139<\/strong> <strong>Well-known Graph App ID<\/strong> The AppId &#8216;00000003-0000-0000-c000-000000000000&#8217; is the fixed global identifier for Microsoft Graph in every tenant. This never changes. Using Get-MgServicePrincipal with this filter is the reliable way to find the Graph service principal regardless of your tenant setup.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Pros and Cons<\/h3>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>\u2705&nbsp; Pros<\/strong><\/td><td><strong>\u274c&nbsp; Cons<\/strong><\/td><\/tr><tr><td>Managed Identity removes client secret headaches<\/td><td>More moving parts \u2014 more things that can break<\/td><\/tr><tr><td>Azure Automation provides proper runbook versioning<\/td><td>Requires an Azure subscription in addition to M365<\/td><\/tr><tr><td>Power Automate handles notifications without code<\/td><td>Debugging cross-service failures means checking logs in multiple places<\/td><\/tr><tr><td>Runbook output persisted to Storage for audit\/trending<\/td><td>Azure Automation module management (keeping SDK current) is manual<\/td><\/tr><tr><td>Scales well \u2014 add more runbooks for different drift scenarios<\/td><td>Initial setup time is significantly higher than pure PowerShell<\/td><\/tr><tr><td>Azure Automation free tier (500 min\/month) covers most small\/medium tenants<\/td><td>Power Automate premium connector still required for HTTP actions<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 5&nbsp; \u00b7&nbsp; <strong>Approach 4: <strong>A Self-Built Configuration Governance Engine<\/strong><\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Full Control Over What You Monitor<\/h2>\n\n\n\n<p style=\"font-size:16px\">This approach requires real development effort, not just scripting. But for organisations that want policy-level drift detection \u2014 \u2018has anyone changed an assignment?\u2019 or \u2018has a compliance policy setting been modified?\u2019 or \u2018was a Conditional Access policy disabled?\u2019 \u2014 building a lightweight baseline comparison engine against the Graph API is entirely achievable. It gives you complete control which is different to UTCM, it works today without additional licensing, and covers exactly the configuration dimensions that matter to your organisation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Three Components<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\">Baseline Snapshot \u2014 a scheduled process that calls Graph API to capture intended state: all policy assignments, configuration profiles, compliance policy requirements. Stored as JSON in Azure Table Storage or similar.<\/li>\n\n\n\n<li style=\"font-size:16px\">Current State Collector \u2014 runs on a schedule to capture actual policy state from your tenant.<\/li>\n\n\n\n<li style=\"font-size:16px\">Diff Engine \u2014 compares snapshot against current state and generates a delta report: what changed, what is missing, what is in conflict.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Capturing a Baseline Snapshot<\/h3>\n\n\n\n<p style=\"font-size:16px\">The snapshot should cover the full breadth of what you care about. The example below captures configuration profiles, compliance policies, Settings Catalog policies, and Conditional Access policies in a single pass:<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td># Capture a comprehensive configuration baseline<br># Uses beta endpoint for richer detail<br>Connect-MgGraph -Scopes &#8216;DeviceManagementConfiguration.Read.All&#8217;,&#8217;Policy.Read.ConditionalAccess&#8217; -NoWelcome<br>&nbsp;<br>$configProfiles = Invoke-MgGraphRequest `<br>&nbsp;&nbsp;&nbsp; -Uri &#8216;https:\/\/graph.microsoft.com\/beta\/deviceManagement\/deviceConfigurations?$expand=assignments&#8217; `<br>&nbsp;&nbsp;&nbsp; -Method GET<br>&nbsp;<br>$compliancePolicies = Invoke-MgGraphRequest `<br>&nbsp;&nbsp;&nbsp; -Uri &#8216;https:\/\/graph.microsoft.com\/beta\/deviceManagement\/deviceCompliancePolicies?$expand=assignments&#8217; `<br>&nbsp;&nbsp;&nbsp; -Method GET<br>&nbsp;<br>$catalogPolicies = Invoke-MgGraphRequest `<br>&nbsp;&nbsp;&nbsp; -Uri &#8216;https:\/\/graph.microsoft.com\/beta\/deviceManagement\/configurationPolicies?$expand=assignments&#8217; `<br>&nbsp;&nbsp;&nbsp; -Method GET<br>&nbsp;<br>$caPolicies = Invoke-MgGraphRequest `<br>&nbsp;&nbsp;&nbsp; -Uri &#8216;https:\/\/graph.microsoft.com\/v1.0\/identity\/conditionalAccess\/policies&#8217; `<br>&nbsp;&nbsp;&nbsp; -Method GET<br>&nbsp;<br>$snapshot = @{<br>&nbsp;&nbsp;&nbsp; CapturedAt&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; = (Get-Date -Format &#8216;o&#8217;)<br>&nbsp;&nbsp;&nbsp; CapturedBy&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; = $env:USERNAME<br>&nbsp;&nbsp;&nbsp; ConfigProfiles&nbsp;&nbsp;&nbsp;&nbsp; = $configProfiles.value<br>&nbsp;&nbsp;&nbsp; CompliancePolicies = $compliancePolicies.value<br>&nbsp;&nbsp;&nbsp; CatalogPolicies&nbsp;&nbsp;&nbsp; = $catalogPolicies.value<br>&nbsp;&nbsp;&nbsp; CAPolicies&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; = $caPolicies.value<br>}<br>&nbsp;<br>$filename = &#8220;baseline_$(Get-Date -Format yyyyMMdd_HHmmss).json&#8221;<br>$snapshot | ConvertTo-Json -Depth 20 | Out-File -FilePath &#8220;.\\baselines\\$filename&#8221; -Encoding UTF8<br>Write-Host &#8220;Baseline captured: $filename&#8221;<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Comparing Baselines<\/h3>\n\n\n\n<p style=\"font-size:16px\">The comparison function checks three types of drift: deleted resources, changed assignments, and modified policy settings (detected via the lastModifiedDateTime timestamp). Conditional Access policies additionally check for state changes (enabled\/disabled):<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td>function Compare-ConfigurationBaselines {<br>&nbsp;&nbsp;&nbsp; param([string]$BaselinePath, [string]$CurrentPath)<br>&nbsp;<br>&nbsp;&nbsp;&nbsp; $baseline = Get-Content $BaselinePath | ConvertFrom-Json<br>&nbsp;&nbsp;&nbsp; $current&nbsp; = Get-Content $CurrentPath&nbsp; | ConvertFrom-Json<br>&nbsp;&nbsp;&nbsp; $driftReport = @()<br>&nbsp;<br>&nbsp;&nbsp;&nbsp; # &#8212; Configuration profiles &#8212;<br>&nbsp;&nbsp;&nbsp; foreach ($bItem in $baseline.ConfigProfiles) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $cItem = $current.ConfigProfiles | Where-Object { $_.id -eq $bItem.id }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if (-not $cItem) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConfigProfile&#8217;; Name=$bItem.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;DELETED&#8217;; Detail=&#8217;Profile no longer exists in tenant&#8217; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; continue<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $bGroups = ($bItem.assignments.target | Sort-Object groupId).groupId -join &#8216;,&#8217;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $cGroups = ($cItem.assignments.target | Sort-Object groupId).groupId -join &#8216;,&#8217;<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if ($bGroups -ne $cGroups) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConfigProfile&#8217;; Name=$bItem.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;ASSIGNMENT_CHANGED&#8217;; Detail=&#8221;Was: $bGroups | Now: $cGroups&#8221; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if ($bItem.lastModifiedDateTime -ne $cItem.lastModifiedDateTime) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConfigProfile&#8217;; Name=$bItem.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;SETTINGS_MODIFIED&#8217;; Detail=&#8221;Modified: $($cItem.lastModifiedDateTime)&#8221; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; }<br>&nbsp;<br>&nbsp;&nbsp;&nbsp; # &#8212; Conditional Access policies (high-value target) &#8212;<br>&nbsp;&nbsp;&nbsp; foreach ($bCA in $baseline.CAPolicies) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $cCA = $current.CAPolicies | Where-Object { $_.id -eq $bCA.id }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if (-not $cCA) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConditionalAccess&#8217;; Name=$bCA.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;DELETED&#8217;; Detail=&#8217;CA policy no longer exists &#8211; INVESTIGATE IMMEDIATELY&#8217; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; continue<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if ($bCA.state -ne $cCA.state) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConditionalAccess&#8217;; Name=$bCA.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;STATE_CHANGED&#8217;; Detail=&#8221;Was: $($bCA.state) | Now: $($cCA.state)&#8221; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; if ($bCA.modifiedDateTime -ne $cCA.modifiedDateTime) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $driftReport += [PSCustomObject]@{ Type=&#8217;ConditionalAccess&#8217;; Name=$bCA.displayName<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; DriftType=&#8217;CONDITIONS_MODIFIED&#8217;; Detail=&#8221;Modified: $($cCA.modifiedDateTime)&#8221; }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; return $driftReport<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-pale-ocean-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\ud83d\udcd6This approach is tenant-configuration level, not device level<\/strong><br>This comparison engine detects changes to your Intune policies and assignments \u2014 not whether individual devices have drifted from those policies. It answers: \u2018Has something changed in how my tenant is configured?\u2019 rather than \u2018Is this specific device compliant?\u2019 Both questions matter. They need different tools. Use this alongside, not instead of, the device-level approaches in Sections 2 and 3.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Pros and Cons<\/h3>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>\u2705 Pros<\/strong><\/td><td><strong>\u274c Cons<\/strong><\/td><\/tr><tr><td>Maximum flexibility \u2014 compare exactly what matters to your org<\/td><td>Significant development and maintenance investment<\/td><\/tr><tr><td>Can detect policy-level drift (assignments changed, policies deleted)<\/td><td>You own the bugs \u2014 and there will be bugs<\/td><\/tr><tr><td>CA policy monitoring included \u2014 critical for security posture<\/td><td>Graph API beta endpoint can change, breaking comparisons silently<\/td><\/tr><tr><td>Persistent baseline enables historical trending<\/td><td>Baseline management requires process discipline (who owns the golden baseline?)<\/td><\/tr><tr><td>No additional licensing beyond existing Graph API access<\/td><td>No UI unless you build one<\/td><\/tr><tr><td>Fully customisable alerting and output<\/td><td>Harder to onboard other admins to a home-grown tool<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:20px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 6&nbsp; \u00b7&nbsp; <strong>Approach 5: Intune&#8217;s Built-In Remediations<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">The Underused Feature<\/h2>\n\n\n\n<p style=\"font-size:16px\">Before going further down the custom-build path, it&#8217;s worth spotlighting a native Intune feature that many IT pros overlook: Remediations (previously called Proactive Remediations before the 2023 rename). This is Intune&#8217;s built-in mechanism for detect-and-fix scripting, and it&#8217;s genuinely excellent for specific drift scenarios.<\/p>\n\n\n\n<p style=\"font-size:16px\">Remediations work by deploying two PowerShell scripts to devices. The detection script exits with code 0 if everything is fine, or code 1 if a problem is detected. The remediation script runs only if the detection exits with code 1, makes the fix, and exits 0 on success. This is the closest thing to real-time, device-level drift correction that Intune provides natively.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">A Practical Example \u2014 Windows Firewall<\/h3>\n\n\n\n<figure style=\"font-size:16px\" class=\"wp-block-table\"><table class=\"has-very-light-gray-to-cyan-bluish-gray-gradient-background has-background has-fixed-layout\"><tbody><tr><td># Detection Script \u2014 checks if Windows Firewall Domain Profile is enabled <br># Exit 0 = compliant (no remediation needed) <br># Exit 1 = non-compliant (trigger remediation) &nbsp; <br><br>try {<br> &nbsp;&nbsp;&nbsp; $fwProfile = Get-NetFirewallProfile -Profile Domain -ErrorAction Stop<br> &nbsp;&nbsp;&nbsp; if ($fwProfile.Enabled -eq $true) { &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<br>       &nbsp;&nbsp; Write-Host &#8216;Domain firewall profile is enabled. Compliant.&#8217; <br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; exit 0 &nbsp;&nbsp;&nbsp;<br>     } else {<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Write-Host &#8216;Domain firewall profile is DISABLED. Non-compliant.&#8217; &nbsp;<br>   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; exit 1 &nbsp;&nbsp;&nbsp;<br>      } } catch {<br> &nbsp;&nbsp;&nbsp;         Write-Host &#8220;Error checking firewall: $($_.Exception.Message)&#8221;<br>      &nbsp;&nbsp;&nbsp; exit 1 <br>} &nbsp; <br><br># \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 &nbsp; <br># Remediation Script \u2014 re-enables Windows Firewall Domain Profile &nbsp; <br><br>try { <br>&nbsp;&nbsp;&nbsp; Set-NetFirewallProfile -Profile Domain -Enabled True -ErrorAction Stop &nbsp;<br> &nbsp;&nbsp; Write-Host &#8216;Domain firewall profile re-enabled successfully.&#8217; &nbsp;<br>   &nbsp; exit 0 <br>} catch { &nbsp;&nbsp;&nbsp; <br>     Write-Host &#8220;Failed to enable firewall: $($_.Exception.Message)&#8221; &nbsp;&nbsp;&nbsp;<br>    exit 1<br> }<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" style=\"font-size:20px\">Licence Requirements<\/h3>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-blush-bordeaux-gradient-background has-background has-fixed-layout\"><tbody><tr><td><strong>\u26a0<\/strong> <strong>Licensing required<\/strong> Remediations require Windows 10\/11 devices with one of: Microsoft Intune Plan 1 with Windows Enterprise E3\/E5, Microsoft 365 Business Premium, or Microsoft 365 F3. NOT available with standard Intune Plan 1 alone on Windows Pro. Check your licence before building scripts that depend on this feature.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Detail<\/strong><\/td><td><strong>Value<\/strong><\/td><\/tr><tr><td>Execution context<\/td><td>SYSTEM by default. Can be configured to run as the logged-in user. SYSTEM context is needed for most remediation actions.<\/td><\/tr><tr><td>Execution frequency<\/td><td>Configurable: once, daily, or hourly. Hourly detection is the closest thing to real-time drift correction in native Intune.<\/td><\/tr><tr><td>Script signing<\/td><td>Not required for Intune Remediations. But test thoroughly \u2014 a bad remediation script running as SYSTEM on thousands of devices is a bad day.<\/td><\/tr><tr><td>Platform coverage<\/td><td>Windows 10\/11 only. No macOS, iOS, or Android support.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Pros and Cons<\/h3>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>\u2705&nbsp; Pros<\/strong><\/td><td><strong>\u274c&nbsp; Cons<\/strong><\/td><\/tr><tr><td>Native Intune feature \u2014 no external infrastructure needed<\/td><td>Requires Windows E3\/E5 or M365 Business Premium licence<\/td><\/tr><tr><td>Can run up to every hour for near-real-time correction<\/td><td>Only works for settings detectable and fixable via PowerShell<\/td><\/tr><tr><td>Results viewable directly in Intune portal per device<\/td><td>Each scenario is a separate script pair \u2014 no centralised logic<\/td><\/tr><tr><td>SYSTEM context enables powerful remediation<\/td><td>Not suitable for detecting policy\/assignment-level drift in the tenant<\/td><\/tr><tr><td>Great for specific, known drift scenarios with deterministic fixes<\/td><td>Running SYSTEM-context scripts at scale requires rigorous testing<\/td><\/tr><tr><td>Scripts versioned and deployed like any Intune policy<\/td><td>Windows only \u2014 no cross-platform support<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure style=\"font-size:20px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 7&nbsp; \u00b7&nbsp; Approach 6.<strong>FREE Community tools<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p style=\"font-size:16px\">When to exhaust all the above options there is naturally one more route you could take: The Community tools path &#8211; The Intune community has been active producing great tools for some time including helpful and automated ways to achieve ways of backup, restore and with some, reporting for drift. While these options are will save you a bunch of time an effort they wont help you learn the under the hood fundamentals for how drift management works. You can check the following tools out.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Microsoft 365 DSC<\/h2>\n\n\n\n<p style=\"font-size:16px\">The heavyweight in this space. It&#8217;s an open-source initiative hosted on GitHub, led by Microsoft engineers and maintained by the community, designed to automate change management by maintaining a single declarative configuration file across all Microsoft 365 workloads. (https:\/\/blog.admindroid.com\/automate-microsoft-365-settings-with-microsoft365dsc\/#How-to-Install-Microsoft365DSC-Module%3F)<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">IntuneCD (Tobias Alm\u00e9n \u2014 Microsoft MVP)<\/h2>\n\n\n\n<p style=\"font-size:16px\">The most sophisticated GitOps approach. IntuneCD is a Python package designed to back up Intune configurations to a Git repository from a DEV environment and automatically detect any alterations and propagate approved changes to the PROD Intune environment. (https:\/\/github.com\/almenscorner\/IntuneCD)<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:0px\">IntuneAutomate (PowerApps &amp; Power Automate)<\/h2>\n\n\n\n<p style=\"font-size:16px\">IntuneAutomate  &#8211; Developed by myself, <strong>Andy Jones<\/strong> this FREE community tool looks to maximise the use of peoples existing Microsoft investments and uses a Power App and Power Automate solution which includes drift management as one of the key tasks. This tool is soon to be released to the community and you can pre-register for the tool here  (https:\/\/kumonix.com\/intuneautomate-power-app).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">TenuVault<\/h2>\n\n\n\n<p style=\"font-size:16px\">Ugur Koc&#8217;s Drift-Relevant Tool <strong>TenuVault<\/strong> is his most directly relevant tool here. It continuously monitors your Intune environment for unauthorized or unexpected changes, using a backup-as-baseline model  GitHub: <code>ugurkocde\/TenuVault<\/code><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">IntuneBackupAndRestore (John Seerden)<\/h2>\n\n\n\n<p style=\"font-size:16px\">A PowerShell module that queries Microsoft Graph and allows for cross-tenant backup and restore of Intune configuration, storing everything as JSON files. It includes <code>Compare-IntuneBackupDirectories<\/code> to compare two backup sets and identify what changed between them. GitHub: <code>jseerden\/IntuneBackupAndRestore<\/code><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<figure style=\"font-size:20px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section 8&nbsp; \u00b7&nbsp; <strong>Choosing the Right Approach<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Decision Framework<\/h2>\n\n\n\n<p style=\"font-size:16px\">None of the approaches here are mutually exclusive. In fact, a mature drift management strategy typically combines several. But if you need a starting point, here&#8217;s a practical approach you may want to take:<\/p>\n\n\n\n<figure style=\"font-size:13px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><td><strong>Scenario<\/strong><\/td><td><strong>Best Approach<\/strong><\/td><td><strong>Effort<\/strong><\/td><td><strong>Notes<\/strong><\/td><\/tr><\/thead><tbody><tr><td><strong>One-off compliance audit<\/strong><\/td><td>PowerShell + Graph API<\/td><td>Low<\/td><td>Run the script, export the CSV. Perfect for quarterly reviews.<\/td><\/tr><tr><td><strong>Ongoing device-state alerting without extra Azure cost<\/strong><\/td><td>Power Automate (Flow A)<\/td><td>Medium<\/td><td>Premium connector required, but no Azure subscription needed.<\/td><\/tr><tr><td><strong>Tenant config change detection (CA policies, profile assignments)<\/strong><\/td><td>Power Automate (Flows B+C) or DIY Baseline Engine<\/td><td>Medium\u2013High<\/td><td>Both approaches detect policy-level changes. Power Automate is lower infrastructure; DIY is more flexible.<\/td><\/tr><tr><td><strong>Auto-fix known device-level drift<\/strong><\/td><td>Intune Remediations<\/td><td>Medium<\/td><td>Requires E3\/E5 or Business Premium. Excellent ROI for common issues.<\/td><\/tr><tr><td><strong>Full detection-to-ticket pipeline<\/strong><\/td><td>Azure Automation + Power Automate<\/td><td>High<\/td><td>Best for mature IT ops with ITSM integration.<\/td><\/tr><tr><td><strong>Prefer a maintained community tool with minimal build effort<\/strong><\/td><td>TenuVault or IntuneCD<\/td><td>Low\u2013Medium<\/td><td>TenuVault is the quickest to get running. IntuneCD is best for GitOps-oriented teams.<\/td><\/tr><tr><td><strong>Broadest M365 coverage + auto-remediation, free<\/strong><\/td><td>Microsoft 365 DSC<\/td><td>High<\/td><td>Most powerful free option. Significant initial setup investment.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" style=\"font-size:20px\">Security Considerations Across All Approaches<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li style=\"font-size:16px\">Least privilege: App registrations and managed identities should have only the Graph API permissions they need. Read-only reporting needs DeviceManagementManagedDevices.Read.All. Add ReadWrite permissions only for remediation actions \u2014 and document the justification.<\/li>\n\n\n\n<li style=\"font-size:16px\">Secret rotation: If using client secrets (not recommended for production), set calendar reminders for rotation before expiry. Drift detection pipelines that go silent because a secret expired are an embarrassingly common issue.<\/li>\n\n\n\n<li style=\"font-size:16px\">Managed Identity first: Where Azure Automation is involved, use system-assigned or user-assigned managed identities instead of client credentials. No secret to manage, no secret to leak.<\/li>\n\n\n\n<li style=\"font-size:16px\">Audit everything: Enable Entra ID audit logging for your app registrations. All Graph API calls made under your app&#8217;s credentials are logged \u2014 when something unexpected happens, you want that trail.<\/li>\n<\/ul>\n\n\n\n<figure style=\"font-size:20px\" class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td>Section \u2014&nbsp; \u00b7&nbsp; <strong>Closing Thoughts<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p style=\"font-size:16px\">Configuration drift is one of those problems that feels manageable right up until it isn\u2019t. The good news is that Intune provides a genuinely rich set of APIs for monitoring and remediating device state, and the tooling available to IT pros \u2014 PowerShell, Power Automate, Azure Automation, Remediations, and a well-developed free community ecosystem \u2014 gives you multiple credible paths to getting drift under control without a significant licensing conversation.<\/p>\n\n\n\n<div style=\"height:15px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<p style=\"font-size:16px\">The honest picture is that all of the approaches here require you to do the work, own the maintenance, and stay on top of API changes. That\u2019s not a knock on any of the tools \u2014 it\u2019s the reality of building your own management plane. The Power Automate baseline comparison flows and the DIY snapshot engine in particular are genuinely capable at detecting tenant configuration drift, including the high-value scenarios like Conditional Access policy changes. They\u2019re not workarounds; for many organisations they\u2019re exactly the right tool.<\/p>\n\n\n\n<div style=\"height:15px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<p style=\"font-size:16px\">Which brings us neatly to the second blog in the series &#8211; <strong>Part 2.<\/strong> <\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-white-color has-text-color has-background has-link-color has-fixed-layout\" style=\"background-color:#002060\"><tbody><tr><td><strong><strong><strong><strong><strong><strong><strong><strong><strong><strong><strong><strong>Part 2: Move aside, UTCM is here! Or is it?<\/strong>\u00a0 : <a href=\"https:\/\/move2modern.uk\/index.php\/2026\/04\/05\/move-aside-utcm-is-here-or-is-it\/\" title=\"\">Episode 2<\/a> -> <\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<div style=\"height:15px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<p style=\"font-size:16px\">Microsoft has built UTCM or Unified Tenant Configuration Management service (<em>Currently in public preview -March 2026)<\/em>. This will be Microsoft\u2019s native answer to exactly this problem. In Part 2, we&#8217;ll look at what UTCM actually does, how it compares to everything here, the real-world licensing picture, and importantly: what UTCM can actually monitor today vs. what the documentation suggests it can monitor (<em>in my own testing experience in March 2026<\/em>). Those two lists are not the same &#8211; and understanding the difference is the difference between a smooth setup and an afternoon of confusing errors.<\/p>\n\n\n\n<div style=\"height:15px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Technical References <\/h3>\n\n\n\n<p style=\"font-size:16px\"><strong>Microsoft Graph API \u2014 Intune Overview \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/graph\/api\/resources\/intune-graph-overview<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Microsoft.Graph PowerShell SDK \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/powershell\/microsoftgraph\/overview<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Intune Managed Device resource (Graph API) \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/graph\/api\/resources\/intune-devices-manageddevice<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>deviceConfigurationState resource type \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/graph\/api\/resources\/intune-deviceconfig-deviceconfigurationstate<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Remediations in Intune \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/mem\/intune\/fundamentals\/remediations<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Azure Automation Overview \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/azure\/automation\/overview<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Power Automate HTTP Connector \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/connectors\/webcontents\/<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>OData query parameters in Graph API \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/graph\/query-parameters<\/p>\n\n\n\n<p style=\"font-size:16px\"><strong>Managed Identities for Azure Automation \u2014 <\/strong>https:\/\/learn.microsoft.com\/en-us\/azure\/automation\/automation-security-overview<\/p>\n","protected":false},"excerpt":{"rendered":"<p>PART 1 OF 2&nbsp; Configuration Drift Management in Microsoft Intune A practical guide to detecting and managing configuration drift using<\/p>\n","protected":false},"author":1,"featured_media":1840,"comment_status":"open","ping_status":"open","sticky":true,"template":"","format":"standard","meta":{"om_disable_all_campaigns":false,"footnotes":""},"categories":[6,3,7,15],"tags":[],"class_list":["post-1820","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-automation","category-intune","category-m365","category-powershell"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/posts\/1820","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/comments?post=1820"}],"version-history":[{"count":25,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/posts\/1820\/revisions"}],"predecessor-version":[{"id":1992,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/posts\/1820\/revisions\/1992"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/media\/1840"}],"wp:attachment":[{"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/media?parent=1820"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/categories?post=1820"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/move2modern.uk\/index.php\/wp-json\/wp\/v2\/tags?post=1820"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}