#Requires -RunAsAdministrator <# .SYNOPSIS Captures endpoint state at three points in the imaging lifecycle so they can be diffed pairwise. .DESCRIPTION Run at three checkpoints: 1) After PPKG enrollment, BEFORE the tech assigns a device category in Intune (the device is enrolled but no category-driven policy/apps have been delivered): .\Capture-LockdownState.ps1 -Stage pre-category 2) After the device category is assigned and the device has synced (apps + policies tied to that category have arrived/started, but lockdown has not yet flipped the user from SupportUser to ShopFloor): .\Capture-LockdownState.ps1 -Stage post-category 3) After lockdown DSC has fully landed (Winlogon DefaultUserName flipped, AssignedAccess kiosk active, SFLD creds populated): .\Capture-LockdownState.ps1 -Stage post-lockdown Output lands under C:\ProgramData\state--\. Copy the whole folder back to the workstation (\\172.16.9.1\image-upload or dump to pxe-images manually) and diff. Diffing tips: pre-category -> post-category : what the category-driven Intune assignments brought in (the SFLD-DSC Win32App + its registry writes + scheduled tasks) post-category -> post-lockdown : what the lockdown DSC profile finished (kiosk shell, autologon flip, AppLocker rules, etc.) .PARAMETER Stage One of 'pre-category', 'post-category', 'post-lockdown'. Controls the output folder name. Legacy 'pre' / 'post' values are still accepted and map to 'pre-category' / 'post-lockdown' respectively. .EXAMPLE .\Capture-LockdownState.ps1 -Stage pre-category #> param( [Parameter(Mandatory = $true)] [ValidateSet('pre-category', 'post-category', 'post-lockdown', 'pre', 'post')] [string]$Stage ) # Map legacy values to new names if ($Stage -eq 'pre') { $Stage = 'pre-category' } if ($Stage -eq 'post') { $Stage = 'post-lockdown' } $ErrorActionPreference = 'Continue' $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $out = "C:\ProgramData\state-$Stage-$stamp" New-Item -ItemType Directory -Path $out -Force | Out-Null function Step($name, [scriptblock]$block) { Write-Host "[$Stage] $name ..." try { & $block } catch { Write-Warning " $name failed: $_" } } # --- MDM policy store: the subkeys that change are the clearest lockdown signal Step "export PolicyManager registry" { reg export 'HKLM\SOFTWARE\Microsoft\PolicyManager\current\device' "$out\PolicyManager.reg" /y | Out-Null } # --- MDM enrollment and provisioning sessions (category-targeted profiles land here) Step "export Enrollments + Provisioning Sessions" { reg export 'HKLM\SOFTWARE\Microsoft\Enrollments' "$out\Enrollments.reg" /y | Out-Null reg export 'HKLM\SOFTWARE\Microsoft\Provisioning\Sessions' "$out\ProvisioningSessions.reg" /y 2>$null | Out-Null reg export 'HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\Accounts' "$out\OMADMAccounts.reg" /y 2>$null | Out-Null } # --- GE-specific state (DSC, credentials, anything under GE\SFLD) Step "export GE\SFLD registry" { reg export 'HKLM\SOFTWARE\GE\SFLD' "$out\SFLD.reg" /y 2>$null | Out-Null reg export 'HKLM\SOFTWARE\WOW6432Node\GE' "$out\GE-WOW6432.reg" /y 2>$null | Out-Null } # --- Intune Management Extension Win32App state (what apps are assigned + detection results) Step "export IME Win32Apps registry" { reg export 'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension' "$out\IME.reg" /y 2>$null | Out-Null } # --- Shell / Winlogon (kiosk replaces Shell value here, autologon values, etc.) Step "export Winlogon" { reg export 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' "$out\Winlogon.reg" /y | Out-Null } # --- AppLocker / software restriction (lockdown commonly adds rules here) Step "export AppLocker + SRP" { reg export 'HKLM\SOFTWARE\Policies\Microsoft\Windows\SrpV2' "$out\AppLocker.reg" /y 2>$null | Out-Null reg export 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Safer\CodeIdentifiers' "$out\SRP.reg" /y 2>$null | Out-Null } # --- WDAC / Code Integrity policies Step "export CI / WDAC" { reg export 'HKLM\SYSTEM\CurrentControlSet\Control\CI' "$out\CI.reg" /y 2>$null | Out-Null if (Test-Path 'C:\Windows\System32\CodeIntegrity\CiPolicies\Active') { Copy-Item 'C:\Windows\System32\CodeIntegrity\CiPolicies\Active\*.cip' "$out\" -Force -EA SilentlyContinue } } # --- Shell Launcher / Assigned Access config (Win11 kiosk feature) Step "export AssignedAccess + ShellLauncher" { reg export 'HKLM\SOFTWARE\Microsoft\Windows\AssignedAccessConfiguration' "$out\AssignedAccess.reg" /y 2>$null | Out-Null reg export 'HKLM\SOFTWARE\Microsoft\ShellLauncher' "$out\ShellLauncher.reg" /y 2>$null | Out-Null } # --- Local users and groups (kiosk accounts get created) Step "snapshot local users and groups" { try { Get-LocalUser | Select Name, Enabled, LastLogon, Description, SID | Export-Csv "$out\LocalUsers.csv" -NoTypeInformation } catch {} try { Get-LocalGroupMember Administrators -EA 0 | Select Name, PrincipalSource, ObjectClass | Export-Csv "$out\Admins.csv" -NoTypeInformation } catch {} } # --- Scheduled tasks (lockdown often registers a monitor/re-enforcer task) Step "snapshot scheduled tasks" { Get-ScheduledTask | Select-Object TaskPath, TaskName, State, @{n='Actions';e={($_.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join '; '}}, @{n='Triggers';e={($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ','}} | Sort-Object TaskPath, TaskName | Export-Csv "$out\Tasks.csv" -NoTypeInformation } # --- Services Step "snapshot services" { Get-Service | Select Name, DisplayName, Status, StartType | Sort Name | Export-Csv "$out\Services.csv" -NoTypeInformation } # --- Installed apps (64 + 32 bit Uninstall hives) Step "snapshot installed apps" { @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | ForEach-Object { Get-ItemProperty $_ -EA 0 } | Where-Object DisplayName | Select DisplayName, DisplayVersion, Publisher, InstallDate | Sort DisplayName -Unique | Export-Csv "$out\InstalledApps.csv" -NoTypeInformation } # --- Desktop contents (lockdown strips desktop icons on kiosk) Step "snapshot desktops" { Get-ChildItem -Path 'C:\Users\Public\Desktop' -ErrorAction SilentlyContinue | Select Name, Length, LastWriteTime | Export-Csv "$out\PublicDesktop.csv" -NoTypeInformation Get-ChildItem -Path 'C:\Users\SupportUser\Desktop' -ErrorAction SilentlyContinue | Select Name, Length, LastWriteTime | Export-Csv "$out\SupportUserDesktop.csv" -NoTypeInformation } # --- Relevant logs Step "copy Intune + Enrollment logs" { $imeDir = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs' $enrollDir = 'C:\Logs' if (Test-Path $imeDir) { New-Item "$out\IME-Logs" -ItemType Directory -Force | Out-Null Copy-Item "$imeDir\*.log" "$out\IME-Logs\" -Force -EA SilentlyContinue } if (Test-Path $enrollDir) { New-Item "$out\GE-Logs" -ItemType Directory -Force | Out-Null Copy-Item "$enrollDir\*.log" "$out\GE-Logs\" -Recurse -Force -EA SilentlyContinue } } # --- dsregcmd status (AAD / Entra join state) Step "dsregcmd /status" { dsregcmd /status 2>&1 | Out-File "$out\dsregcmd.txt" } # --- Intune readiness probe (4-layer gate, frozen at this snapshot moment). # Aligned with Microsoft's documented IME / ESP gates - see: # https://learn.microsoft.com/en-us/windows/client-management/mdm-diagnose-enrollment # https://patchmypc.com/blog/intune-management-extension-esp-phases/ # # Layer 1 - AAD + MDM enrollment object exists # AzureAdJoined, IntuneEnrolled (HKLM\Enrollments\ EnrollmentState=1) # Layer 2 - Microsoft's own success markers # MdmEnrollEvent75Found ("Auto MDM Enroll: Succeeded" in DeviceManagement-Enterprise-Diagnostics-Provider/Admin) # No MdmEnrollEvent76Found (failure) within last 7d # HasProvisioningCompleted=1 in OMADM\Accounts\ # FirstSync IsSyncDone=1 in Enrollments\\FirstSync # Layer 3 - Policy actually delivered (CSP succeeded, not just provider registered) # PolicyManager\Providers\\default\Device exists # PolicyManager\current\device has subkeys # Layer 4 - IME running (Win32App / PowerShell channel ready) # IME service running, IME log dir populated # Sidecar Policy Provider InstallationState=Completed (best-effort log grep) # # Pre-category snapshots that show layers 2-3 red = category assigned too # early -> reboot races the policy/payload pull -> stalled deploy -> re-image. Step "intune category-readiness probe" { $r = [ordered]@{ # Layer 1 AzureAdJoined = $false AzureAdTenant = $null DeviceId = $null IntuneEnrolled = $false EnrollmentId = $null # Layer 2 MdmEnrollEvent75Found = $false MdmEnrollEvent75Time = $null MdmEnrollEvent76Found = $false MdmEnrollEvent76Time = $null HasProvisioningCompleted = $false FirstSyncIsSyncDone = $false # Layer 3 PolicyManagerProviderForEnrollment = $false PolicyManagerCurrentDeviceSubkeys = 0 # Layer 4 ImeServiceRunning = $false ImeLogDirPopulated = $false SidecarInstallationState = $null # 'Completed' | 'InProgress' | $null # Misc LastSyncEventTime = $null Stage = $Stage SnapshotTime = $null } # Layer 1: AAD + tenant + DeviceId try { $ds = & dsregcmd /status 2>&1 | Out-String if ($ds -match 'AzureAdJoined\s*:\s*YES') { $r.AzureAdJoined = $true } if ($ds -match 'TenantId\s*:\s*(\S+)') { $r.AzureAdTenant = $matches[1] } if ($ds -match '(?m)^\s*DeviceId\s*:\s*(\S+)') { $r.DeviceId = $matches[1] } } catch {} # Layer 1: enrollment record try { $eks = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue | Where-Object { $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue $p -and $p.EnrollmentState -eq 1 -and $_.PSChildName -ne 'Context' } if ($eks) { $r.IntuneEnrolled = $true $r.EnrollmentId = $eks[0].PSChildName } } catch {} # Layer 2: event 75 (success) + 76 (failure) in last 7d try { $cutoff = (Get-Date).AddDays(-7) $evts = Get-WinEvent -LogName 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin' -MaxEvents 1000 -ErrorAction SilentlyContinue | Where-Object { $_.TimeCreated -gt $cutoff } $e75 = $evts | Where-Object { $_.Id -eq 75 } | Sort-Object TimeCreated -Descending | Select-Object -First 1 $e76 = $evts | Where-Object { $_.Id -eq 76 } | Sort-Object TimeCreated -Descending | Select-Object -First 1 if ($e75) { $r.MdmEnrollEvent75Found = $true; $r.MdmEnrollEvent75Time = $e75.TimeCreated.ToString('o') } if ($e76) { $r.MdmEnrollEvent76Found = $true; $r.MdmEnrollEvent76Time = $e76.TimeCreated.ToString('o') } $latest = $evts | Sort-Object TimeCreated -Descending | Select-Object -First 1 if ($latest) { $r.LastSyncEventTime = $latest.TimeCreated.ToString('o') } } catch {} # Layer 2 + 3: per-enrollment regs (only meaningful with an EnrollmentId) if ($r.EnrollmentId) { try { $omadm = "HKLM:\SOFTWARE\Microsoft\Provisioning\OMADM\Accounts\$($r.EnrollmentId)" if (Test-Path $omadm) { $v = (Get-ItemProperty -Path $omadm -Name HasProvisioningCompleted -ErrorAction SilentlyContinue).HasProvisioningCompleted if ($v -eq 1) { $r.HasProvisioningCompleted = $true } } } catch {} try { $fs = "HKLM:\SOFTWARE\Microsoft\Enrollments\$($r.EnrollmentId)\FirstSync" if (Test-Path $fs) { $v = (Get-ItemProperty -Path $fs -Name IsSyncDone -ErrorAction SilentlyContinue).IsSyncDone if ($v -eq 1) { $r.FirstSyncIsSyncDone = $true } } } catch {} try { $pp = "HKLM:\SOFTWARE\Microsoft\PolicyManager\Providers\$($r.EnrollmentId)\default\Device" if (Test-Path $pp) { $r.PolicyManagerProviderForEnrollment = $true } } catch {} } # Layer 3: actual policy mirror under current\device try { $cur = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue $r.PolicyManagerCurrentDeviceSubkeys = if ($cur) { $cur.Count } else { 0 } } catch {} # Layer 4: IME service + logs try { $svc = Get-Service -Name 'IntuneManagementExtension' -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq 'Running') { $r.ImeServiceRunning = $true } $imeLog = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs' if ((Test-Path $imeLog) -and (Get-ChildItem $imeLog -ErrorAction SilentlyContinue | Select-Object -First 1)) { $r.ImeLogDirPopulated = $true } } catch {} # Layer 4: Sidecar Policy Provider InstallationState (best-effort grep of IME log) try { $imeLogFile = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log' if (Test-Path $imeLogFile) { $hits = Select-String -Path $imeLogFile -Pattern 'Sidecar.*?[Ii]nstallation\s*[Ss]tate.*?(Completed|InProgress|NotStarted|Failed)' -ErrorAction SilentlyContinue | Select-Object -Last 1 if ($hits -and $hits.Matches.Count -gt 0 -and $hits.Matches[0].Groups.Count -ge 2) { $r.SidecarInstallationState = $hits.Matches[0].Groups[1].Value } } } catch {} # Verdict $r.SnapshotTime = (Get-Date).ToString('o') $r.ReadyForCategoryAssignment = ( $r.AzureAdJoined -and $r.IntuneEnrolled -and $r.MdmEnrollEvent75Found -and (-not $r.MdmEnrollEvent76Found) -and $r.HasProvisioningCompleted -and $r.FirstSyncIsSyncDone -and $r.PolicyManagerProviderForEnrollment -and ($r.PolicyManagerCurrentDeviceSubkeys -gt 0) -and $r.ImeServiceRunning -and $r.ImeLogDirPopulated ) [pscustomobject]$r | ConvertTo-Json -Depth 3 | Out-File "$out\intune-readiness.json" # Human-readable summary (PS 5.1-safe: $(if ...) subexpressions, no inline-if-as-expression) @( "Intune category-readiness probe @ $($r.SnapshotTime) (stage=$Stage)" '------------------------------------------------------------------' ' Layer 1 - AAD + MDM enrollment object' (" [{0}] AzureAdJoined tenant={1}" -f $(if ($r.AzureAdJoined) {'OK '} else {'FAIL'}), $r.AzureAdTenant) (" [{0}] IntuneEnrolled id={1}" -f $(if ($r.IntuneEnrolled) {'OK '} else {'FAIL'}), $r.EnrollmentId) ' Layer 2 - Microsoft success markers' (" [{0}] Event 75 (Auto MDM Enroll: Succeeded) time={1}" -f $(if ($r.MdmEnrollEvent75Found) {'OK '} else {'FAIL'}), $r.MdmEnrollEvent75Time) (" [{0}] No event 76 in last 7d (no enroll failure) lastFail={1}" -f $(if ($r.MdmEnrollEvent76Found) {'FAIL'} else {'OK '}), $r.MdmEnrollEvent76Time) (" [{0}] OMADM HasProvisioningCompleted=1" -f $(if ($r.HasProvisioningCompleted) {'OK '} else {'FAIL'})) (" [{0}] Enrollments\\FirstSync IsSyncDone=1" -f $(if ($r.FirstSyncIsSyncDone) {'OK '} else {'FAIL'})) ' Layer 3 - policy actually delivered' (" [{0}] PolicyManager\Providers\\default\Device exists" -f $(if ($r.PolicyManagerProviderForEnrollment) {'OK '} else {'FAIL'})) (" [{0}] PolicyManager\current\device subkey count={1}" -f $(if ($r.PolicyManagerCurrentDeviceSubkeys -gt 0) {'OK '} else {'FAIL'}), $r.PolicyManagerCurrentDeviceSubkeys) ' Layer 4 - IME running (Win32App / PS1 channel)' (" [{0}] IME service running" -f $(if ($r.ImeServiceRunning) {'OK '} else {'FAIL'})) (" [{0}] IME log dir populated" -f $(if ($r.ImeLogDirPopulated) {'OK '} else {'FAIL'})) (" [--] Sidecar InstallationState (best-effort) value={0}" -f $(if ($r.SidecarInstallationState) { $r.SidecarInstallationState } else { '(unknown)' })) '------------------------------------------------------------------' ("ReadyForCategoryAssignment: $($r.ReadyForCategoryAssignment)") ("DeviceId: $($r.DeviceId)") ("LastMDMEvent: $($r.LastSyncEventTime)") '' 'Notes:' ' - All layers must be green before assigning device category in Intune.' ' - Even when ready locally, allow >=15 min after adding the user/device' ' to the target Entra group (server-side group eval delay, MS docs).' ' - Pre-category snapshot with red Layer 2/3 = root cause for "imaging' ' stalled and required re-image" (category raced the policy pull).' ) | Out-File "$out\intune-readiness.txt" } # --- SFLD credential manager YAML (identifies the Intune category) Step "snapshot SFLD CredentialManager dir" { $cmDir = 'C:\ProgramData\SFLD\CredentialManager' if (Test-Path $cmDir) { Get-ChildItem $cmDir -Recurse | Select FullName, Length, LastWriteTime | Export-Csv "$out\SFLD-CredentialManager.csv" -NoTypeInformation } } # --- SFLD DSC payload directory (where the DSC bootstrap drops scripts/creds) Step "snapshot SFLD DSC dir" { $dscDir = 'C:\ProgramData\SFLD\DSC' if (Test-Path $dscDir) { Get-ChildItem $dscDir -Recurse -Force | Select FullName, Length, LastWriteTime | Export-Csv "$out\SFLD-DSC-Files.csv" -NoTypeInformation } } # --- DSCDeployment.log (sits at C:\pc\, not under C:\Logs - written by the # SFLD-DSC Win32App bootstrap. Critical for diagnosing version drift, # missing sastoken.txt, fallback-path failures.) Step "copy DSCDeployment + version artifacts" { foreach ($p in 'C:\pc\DSCDeployment.log','C:\pc\version.txt') { if (Test-Path $p) { Copy-Item $p "$out\" -Force -EA SilentlyContinue } } } # --- IMECache file listing per Win32App content folder. Each subdir is # _; the file list tells us which content # payloads landed correctly (e.g. sastoken.txt presence/absence). # Avoid copying .intunewin blobs (huge, encrypted) - just list. Step "snapshot IMECache file listing" { $imeContent = 'C:\Windows\IMECache' if (Test-Path $imeContent) { Get-ChildItem $imeContent -Recurse -File -Force -EA SilentlyContinue | Select FullName, Length, LastWriteTime | Export-Csv "$out\IMECache-Files.csv" -NoTypeInformation } } # --- BPRT (PPKG bulk enrollment) runtime state. Setup-WCD.ps1 dumps # packageInfo.json + criticalChecks.json here; their values prove # whether PPKG parse + critical checks passed. Step "copy BPRT runtime state" { $bprtDir = 'C:\Logs\BPRT' if (Test-Path $bprtDir) { New-Item "$out\BPRT" -ItemType Directory -Force | Out-Null Copy-Item "$bprtDir\*" "$out\BPRT\" -Recurse -Force -EA SilentlyContinue } } # --- C:\WCDApps state (where Setup-WCD.ps1 deploys helper scripts) Step "snapshot WCDApps dir" { $wcd = 'C:\WCDApps' if (Test-Path $wcd) { Get-ChildItem $wcd -Recurse -File -Force -EA SilentlyContinue | Select FullName, Length, LastWriteTime | Export-Csv "$out\WCDApps-Files.csv" -NoTypeInformation } } # --- Windows Provisioning Diagnostics (Microsoft-side PPKG runtime logs) Step "copy Windows Provisioning Diagnostics" { $pdiag = 'C:\Windows\Provisioning\Diagnostics' if (Test-Path $pdiag) { New-Item "$out\Provisioning-Diagnostics" -ItemType Directory -Force | Out-Null Copy-Item "$pdiag\*" "$out\Provisioning-Diagnostics\" -Recurse -Force -EA SilentlyContinue } } # --- Scheduled task last-run details (need LastRunTime + LastTaskResult, not just State) Step "snapshot scheduled task run history" { Get-ScheduledTask | ForEach-Object { $info = $_ | Get-ScheduledTaskInfo -EA SilentlyContinue [pscustomobject]@{ TaskPath = $_.TaskPath TaskName = $_.TaskName State = $_.State LastRunTime = $info.LastRunTime LastTaskResult = $info.LastTaskResult NextRunTime = $info.NextRunTime NumberOfMissedRuns = $info.NumberOfMissedRuns } } | Sort TaskPath, TaskName | Export-Csv "$out\Tasks-RunHistory.csv" -NoTypeInformation } # --- DeviceManagement event log (outbound MDM sync errors - 429 throttles, # AAD token failures, policy-pull stalls. Not visible in IME log alone.) Step "export DeviceManagement event log" { Get-WinEvent -LogName 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin' ` -MaxEvents 500 -EA SilentlyContinue | Select TimeCreated, Id, LevelDisplayName, Message | Export-Csv "$out\DeviceManagement-Events.csv" -NoTypeInformation } # --- Provisioning event log (PPKG application errors, runtime issues) Step "export Provisioning event log" { Get-WinEvent -LogName 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' ` -MaxEvents 500 -EA SilentlyContinue | Select TimeCreated, Id, LevelDisplayName, Message | Export-Csv "$out\Provisioning-Events.csv" -NoTypeInformation } # --- MDM device certificate (confirms enrollment is healthy at the cert layer) Step "snapshot MDM certificates" { Get-ChildItem Cert:\LocalMachine\My -EA SilentlyContinue | Where-Object { $_.Subject -match 'MS-Organization|MS-Device|Microsoft Intune' -or $_.Issuer -match 'Microsoft Intune' } | Select Subject, Issuer, Thumbprint, NotBefore, NotAfter | Export-Csv "$out\MDM-Certificates.csv" -NoTypeInformation } Write-Host "" Write-Host "Snapshot complete: $out" Write-Host "" Write-Host "Next: copy that folder to the workstation. Easiest:" Write-Host " net use Z: \\172.16.9.1\image-upload /user:pxe-upload pxe /persistent:no" Write-Host " robocopy `"$out`" `"Z:\state-$Stage-$stamp`" /E /NFL /NDL /NJH /NJS" Write-Host " net use Z: /delete /y"