<# .SYNOPSIS Collects the current state of Microsoft Purview Endpoint DLP on this Windows device into a single JSON snapshot, for on-device troubleshooting / testing. .DESCRIPTION "Purview Assistant" companion collector. Run this on an onboarded Windows device to capture: * Device / OS / join state (Azure AD, domain, workplace) * Defender for Endpoint onboarding state (OnboardingState, OrgId, Device ID) * Sense + Defender service health * Microsoft Defender Antivirus configuration that Endpoint DLP depends on (real-time protection, behavior monitoring, engine / platform versions) * The Endpoint DLP client binaries and whether they are running * The cached DLP policy (dlpPolicy.json / dlpWebSitesPolicy.json / dlpActionsOverridePolicy.json) when it can be located, or read from an MDE Client Analyzer result folder you point it at The script writes ONE .json file. Open it in the Endpoint DLP page of Purview Assistant (endpoint-dlp.html) to see a decoded, human-readable view of policies, rules, and enforcement modes. The JSON never leaves the device unless you choose to move it; the viewer page loads it entirely in-browser. Nothing is sent anywhere by this script - it only reads local state and writes a local file. .PARAMETER AnalyzerResultPath Optional path to an extracted MDE Client Analyzer result folder (MDEClientAnalyzerResult_) or its \DLP subfolder. When supplied, the cached policy is read from the JSON files there instead of (or in addition to) auto-discovery. This is the most reliable source for the policy, since the live on-disk cache is not always present in a readable form. .PARAMETER OutFile Output path for the JSON snapshot. Defaults to .\EndpointDlpState__.json in the current directory. .PARAMETER IncludeFileEAs Also embed FileEAs.txt (per-file classification from an analyzer run) when present. Off by default because it can contain file paths from the repro. .PARAMETER Quiet Suppress the console summary; just write the JSON. .EXAMPLE .\Get-EndpointDlpState.ps1 Collect live state and auto-discover any cached policy, write JSON to the current folder, and print a summary. .EXAMPLE .\Get-EndpointDlpState.ps1 -AnalyzerResultPath C:\Temp\MDEClientAnalyzerResult_ABC123 Also read the decoded DLP policy from an MDE Client Analyzer result folder. .NOTES Run in an elevated PowerShell prompt for complete results (reading Defender state and the ProgramData policy cache can require administrator rights). Compatible with Windows PowerShell 5.1 and PowerShell 7+. #> [CmdletBinding()] param( [string]$AnalyzerResultPath, [string]$OutFile, [switch]$IncludeFileEAs, [switch]$Quiet ) $ErrorActionPreference = 'Continue' $errors = New-Object System.Collections.Generic.List[string] function Add-CollectError([string]$where, $ex) { $msg = if ($ex -is [System.Management.Automation.ErrorRecord]) { $ex.Exception.Message } else { "$ex" } $errors.Add("$where`: $msg") | Out-Null } # --- helpers --------------------------------------------------------------- function Get-RegValues([string]$path) { # Return a hashtable of value-name -> value for a registry key, or $null. try { if (-not (Test-Path $path)) { return $null } $key = Get-Item -LiteralPath $path -ErrorAction Stop $out = [ordered]@{} foreach ($name in $key.GetValueNames()) { $display = if ($name -eq '') { '(default)' } else { $name } $out[$display] = $key.GetValue($name) } return $out } catch { Add-CollectError "registry $path" $_; return $null } } function Get-ExeInfo([string]$path) { try { if (-not (Test-Path -LiteralPath $path)) { return $null } $item = Get-Item -LiteralPath $path -ErrorAction Stop return [ordered]@{ path = $item.FullName version = $item.VersionInfo.ProductVersion present = $true } } catch { Add-CollectError "exe $path" $_; return $null } } $isElevated = $false try { $wi = [System.Security.Principal.WindowsIdentity]::GetCurrent() $wp = New-Object System.Security.Principal.WindowsPrincipal($wi) $isElevated = $wp.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { } # --- device / OS ----------------------------------------------------------- $device = [ordered]@{} try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $device = [ordered]@{ hostname = $env:COMPUTERNAME osCaption = $os.Caption osVersion = $os.Version osBuild = $os.BuildNumber osArch = $os.OSArchitecture lastBootUtc = ($os.LastBootUpTime).ToUniversalTime().ToString('o') manufacturer = $cs.Manufacturer model = $cs.Model partOfDomain = [bool]$cs.PartOfDomain domain = $cs.Domain } } catch { Add-CollectError 'device' $_; $device.hostname = $env:COMPUTERNAME } # Azure AD / workplace join (Endpoint DLP requires AAD- or workplace-joined). $join = [ordered]@{} try { $ds = & dsregcmd /status 2>$null if ($ds) { foreach ($field in 'AzureAdJoined','EnterpriseJoined','DomainJoined','WorkplaceJoined','TenantName','TenantId') { $line = $ds | Where-Object { $_ -match "^\s*$field\s*:\s*(.+?)\s*$" } | Select-Object -First 1 if ($line -and $line -match ":\s*(.+?)\s*$") { $join[$field] = $matches[1] } } } } catch { Add-CollectError 'dsregcmd' $_ } # --- onboarding (registry) ------------------------------------------------- $atpStatus = Get-RegValues 'HKLM:\SOFTWARE\Microsoft\Windows Advanced Threat Protection\Status' $atpRoot = Get-RegValues 'HKLM:\SOFTWARE\Microsoft\Windows Advanced Threat Protection' $atpPolicy = Get-RegValues 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Advanced Threat Protection' $onboarding = [ordered]@{ onboardingState = if ($atpStatus -and $atpStatus.Contains('OnboardingState')) { [int]$atpStatus['OnboardingState'] } else { $null } orgId = if ($atpStatus -and $atpStatus.Contains('OrgId')) { "$($atpStatus['OrgId'])" } else { $null } lastConnected = if ($atpStatus -and $atpStatus.Contains('LastConnected')) { "$($atpStatus['LastConnected'])" } else { $null } senseId = if ($atpRoot -and $atpRoot.Contains('senseId')) { "$($atpRoot['senseId'])" } else { $null } statusKey = $atpStatus policyKey = $atpPolicy } # --- services -------------------------------------------------------------- $services = @() foreach ($svc in 'Sense','WinDefend','MDCoreSvc','MpsSvc','wscsvc','SenseCE') { try { $s = Get-Service -Name $svc -ErrorAction Stop $services += [ordered]@{ name = $s.Name displayName = $s.DisplayName status = "$($s.Status)" startType = "$($s.StartType)" } } catch { } # service may not exist on all SKUs; that itself is informative but not an error } # --- Defender AV configuration (what Endpoint DLP config-status checks) ----- $defender = [ordered]@{} try { $mp = Get-MpComputerStatus -ErrorAction Stop $defender = [ordered]@{ realTimeProtectionEnabled = [bool]$mp.RealTimeProtectionEnabled behaviorMonitorEnabled = [bool]$mp.BehaviorMonitorEnabled antivirusEnabled = [bool]$mp.AntivirusEnabled antispywareEnabled = [bool]$mp.AntispywareEnabled isTamperProtected = [bool]$mp.IsTamperProtected amRunningMode = "$($mp.AMRunningMode)" amEngineVersion = "$($mp.AMEngineVersion)" amProductVersion = "$($mp.AMProductVersion)" # a.k.a. platform / "Mocamp" version amServiceVersion = "$($mp.AMServiceVersion)" nisEnabled = [bool]$mp.NISEnabled } } catch { Add-CollectError 'Get-MpComputerStatus' $_ # Fallback via WMI if the Defender module cmdlets are unavailable. try { $mp = Get-CimInstance -Namespace 'root/Microsoft/Windows/Defender' -ClassName MSFT_MpComputerStatus -ErrorAction Stop $defender = [ordered]@{ realTimeProtectionEnabled = [bool]$mp.RealTimeProtectionEnabled behaviorMonitorEnabled = [bool]$mp.BehaviorMonitorEnabled antivirusEnabled = [bool]$mp.AntivirusEnabled amEngineVersion = "$($mp.AMEngineVersion)" amProductVersion = "$($mp.AMProductVersion)" amServiceVersion = "$($mp.AMServiceVersion)" } } catch { Add-CollectError 'MSFT_MpComputerStatus' $_ } } try { $pref = Get-MpPreference -ErrorAction Stop $defender.disableRealtimeMonitoring = [bool]$pref.DisableRealtimeMonitoring $defender.disableBehaviorMonitoring = [bool]$pref.DisableBehaviorMonitoring } catch { Add-CollectError 'Get-MpPreference' $_ } # --- Endpoint DLP client binaries & processes ------------------------------ $dlpProcNames = 'MpDlpService','MpDlpCmd','MipDlp','DlpUserAgent','SenseDlpProcessor','MsSense','SenseCE' $running = @{} try { foreach ($p in Get-Process -Name $dlpProcNames -ErrorAction SilentlyContinue) { $running[$p.ProcessName] = $true } } catch { } $dlpBinaries = @() $platformGlob = 'C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.*' $latestPlatform = $null # Sort by parsed version, not name: lexically "4.18.24010.9-0" > "4.18.24010.10-0". try { $latestPlatform = Get-ChildItem -Path $platformGlob -Directory -ErrorAction SilentlyContinue | Sort-Object { try { [version]($_.Name -replace '-.*$','') } catch { [version]'0.0' } } | Select-Object -Last 1 } catch { } $binMap = [ordered]@{ 'MpDlpService' = if ($latestPlatform) { Join-Path $latestPlatform.FullName 'MpDlpService.exe' } else { $null } 'MpDlpCmd' = if ($latestPlatform) { Join-Path $latestPlatform.FullName 'MpDlpCmd.exe' } else { $null } 'MipDlp' = if ($latestPlatform) { Join-Path $latestPlatform.FullName 'MipDlp.exe' } else { $null } 'DlpUserAgent' = if ($latestPlatform) { Join-Path $latestPlatform.FullName 'DlpUserAgent.exe' } else { $null } 'SenseDlpProcessor' = 'C:\Program Files\Windows Defender Advanced Threat Protection\SenseDlpProcessor.exe' 'SenseCE' = 'C:\Program Files\Windows Defender Advanced Threat Protection\Classification\SenseCE.exe' } foreach ($name in $binMap.Keys) { $info = if ($binMap[$name]) { Get-ExeInfo $binMap[$name] } else { $null } $dlpBinaries += [ordered]@{ name = $name present = [bool]$info path = if ($info) { $info.path } else { $binMap[$name] } version = if ($info) { $info.version } else { $null } running = [bool]$running[$name] } } $defenderPlatformVersion = if ($latestPlatform) { $latestPlatform.Name } else { $null } # --- cached DLP policy ----------------------------------------------------- $policy = [ordered]@{ found = $false source = $null files = [ordered]@{ dlpPolicy = $null dlpWebSitesPolicy = $null dlpActionsOverridePolicy = $null dlpSensitiveInfoTypesPolicy = $null fileEAs = $null } } # Large policy files are kept OUT of ConvertTo-Json: its string escaper is quadratic on # multi-MB input and effectively hangs on a real tenant's dlpPolicy.json. Each rawText is # replaced by a short placeholder token here; the real content is escaped quickly and # spliced into the JSON at write time. $rawInjections = [ordered]@{} $canFastEncode = $true try { Add-Type -AssemblyName System.Web -ErrorAction Stop } catch { $canFastEncode = $false; Add-CollectError 'load System.Web' $_ } function New-RawToken($content) { $t = '@@RAW_' + [guid]::NewGuid().ToString('N') + '@@' $rawInjections[$t] = [string]$content return $t } function Read-PolicyFile([string]$path) { try { if (-not (Test-Path -LiteralPath $path)) { return $null } $resolved = (Resolve-Path -LiteralPath $path).Path $content = Get-Content -LiteralPath $path -Raw -ErrorAction Stop return [ordered]@{ path = $resolved; rawText = (New-RawToken $content) } } catch { Add-CollectError "policy file $path" $_; return $null } } $targetNames = @('dlpPolicy.json','dlpWebSitesPolicy.json','dlpActionsOverridePolicy.json','dlpSensitiveInfoTypesPolicy.json') if ($IncludeFileEAs) { $targetNames += 'FileEAs.txt' } $keyMap = @{ 'dlpPolicy.json' = 'dlpPolicy' 'dlpWebSitesPolicy.json' = 'dlpWebSitesPolicy' 'dlpActionsOverridePolicy.json' = 'dlpActionsOverridePolicy' 'dlpSensitiveInfoTypesPolicy.json' = 'dlpSensitiveInfoTypesPolicy' 'FileEAs.txt' = 'fileEAs' } # Record a found policy file (mutates the shared $policy object). function Set-PolicyHit($file) { $key = $keyMap[$file.Name] if (-not $key -or $policy.files[$key]) { return } $read = Read-PolicyFile $file.FullName if ($read) { $policy.files[$key] = $read if ($key -ne 'fileEAs') { $policy.found = $true } if (-not $policy.source) { $policy.source = if ($AnalyzerResultPath) { "analyzer result: $($file.DirectoryName)" } else { "auto-discovered: $($file.DirectoryName)" } } } } function Test-AllPolicyFound { $policy.files.dlpPolicy -and $policy.files.dlpWebSitesPolicy -and $policy.files.dlpActionsOverridePolicy } # Bounded, reparse-point-safe directory walk. Deliberately does NOT use Get-ChildItem # -Recurse: on a real MDE Client Analyzer result (or the ProgramData tree) -Recurse can # follow junctions/reparse points and hang in Windows PowerShell 5.1. This walks a queue # we control, skips reparse-point dirs and the huge DataCollection tree, and caps depth. function Get-MatchingFiles([string]$root, [string[]]$names, [int]$maxDepth) { $out = New-Object System.Collections.Generic.List[object] if (-not (Test-Path -LiteralPath $root)) { return $out } $queue = New-Object System.Collections.Generic.Queue[object] $queue.Enqueue([pscustomobject]@{ Path = $root; Depth = 0 }) while ($queue.Count -gt 0) { $node = $queue.Dequeue() $items = $null try { $items = Get-ChildItem -LiteralPath $node.Path -Force -ErrorAction SilentlyContinue } catch { } foreach ($it in $items) { if ($it.PSIsContainer) { if ($node.Depth -lt $maxDepth -and $it.Name -ne 'DataCollection' -and -not ($it.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $queue.Enqueue([pscustomobject]@{ Path = $it.FullName; Depth = $node.Depth + 1 }) } } elseif ($names -contains $it.Name) { $out.Add($it) } } } return $out } if ($AnalyzerResultPath) { if (Test-Path -LiteralPath $AnalyzerResultPath) { # The analyzer writes these directly under \DLP. Resolve by exact path first # (instant, no enumeration), then fall back to a bounded manual walk if needed. if (-not $Quiet) { Write-Host " reading cached policy from analyzer result..." -ForegroundColor DarkGray } foreach ($dir in @((Join-Path $AnalyzerResultPath 'DLP'), $AnalyzerResultPath)) { foreach ($fname in $targetNames) { if ($policy.files[$keyMap[$fname]]) { continue } $p = Join-Path $dir $fname if (Test-Path -LiteralPath $p -PathType Leaf) { Set-PolicyHit (Get-Item -LiteralPath $p) } } } if (-not (Test-AllPolicyFound)) { if (-not $Quiet) { Write-Host " not all files were directly under \DLP; scanning the analyzer folder (bounded)..." -ForegroundColor DarkGray } foreach ($f in (Get-MatchingFiles $AnalyzerResultPath $targetNames 4)) { Set-PolicyHit $f } } } else { Add-CollectError 'AnalyzerResultPath' "Path not found: $AnalyzerResultPath" } } else { # Best-effort auto-discovery of a live on-disk cache (usually absent), bounded and safe. if (-not $Quiet) { Write-Host " searching for a live cached policy (bounded)..." -ForegroundColor DarkGray } foreach ($d in @( 'C:\ProgramData\Microsoft\Windows Defender Advanced Threat Protection\DLP', 'C:\ProgramData\Microsoft\Windows Defender Advanced Threat Protection\Cyber' )) { if (Test-AllPolicyFound) { break } foreach ($f in (Get-MatchingFiles $d $targetNames 2)) { Set-PolicyHit $f } } } # --- derived quick checks (mirrored, richer, in the viewer page) ----------- function New-Check($id, $label, $status, $detail) { [ordered]@{ id = $id; label = $label; status = $status; detail = $detail } } $checks = @() $checks += New-Check 'onboarded' 'Onboarded to Defender for Endpoint' ` ($(if ($onboarding.onboardingState -eq 1) { 'pass' } elseif ($null -eq $onboarding.onboardingState) { 'unknown' } else { 'fail' })) ` "OnboardingState = $($onboarding.onboardingState)" $sense = $services | Where-Object { $_.name -eq 'Sense' } | Select-Object -First 1 $checks += New-Check 'sense' 'Sense (MDE) service running' ` ($(if ($sense -and $sense.status -eq 'Running') { 'pass' } elseif (-not $sense) { 'unknown' } else { 'fail' })) ` "$(if ($sense) { "$($sense.status) / $($sense.startType)" } else { 'service not found' })" $checks += New-Check 'rtp' 'Defender real-time protection on' ` ($(if ($defender.Contains('realTimeProtectionEnabled')) { if ($defender.realTimeProtectionEnabled) { 'pass' } else { 'fail' } } else { 'unknown' })) ` "RealTimeProtectionEnabled = $($defender.realTimeProtectionEnabled)" $checks += New-Check 'bm' 'Defender behavior monitoring on' ` ($(if ($defender.Contains('behaviorMonitorEnabled')) { if ($defender.behaviorMonitorEnabled) { 'pass' } else { 'fail' } } else { 'unknown' })) ` "BehaviorMonitorEnabled = $($defender.behaviorMonitorEnabled)" $dlpSvcBin = $dlpBinaries | Where-Object { $_.name -eq 'MpDlpService' } | Select-Object -First 1 $checks += New-Check 'dlpsvc' 'Endpoint DLP service (MpDlpService) running' ` ($(if ($dlpSvcBin -and $dlpSvcBin.running) { 'pass' } elseif ($dlpSvcBin -and $dlpSvcBin.present) { 'warn' } else { 'unknown' })) ` "$(if ($dlpSvcBin) { "present=$($dlpSvcBin.present), running=$($dlpSvcBin.running)" } else { 'not evaluated' })" $aadOk = ($join.AzureAdJoined -eq 'YES' -or $join.WorkplaceJoined -eq 'YES') $checks += New-Check 'join' 'Azure AD or Workplace joined' ` ($(if ($join.Count -eq 0) { 'unknown' } elseif ($aadOk) { 'pass' } else { 'fail' })) ` "AzureAdJoined=$($join.AzureAdJoined), WorkplaceJoined=$($join.WorkplaceJoined)" $checks += New-Check 'policy' 'Cached DLP policy located' ` ($(if ($policy.found) { 'pass' } else { 'warn' })) ` "$(if ($policy.found) { $policy.source } else { 'No cached policy JSON found. Run the MDE Client Analyzer and re-run with -AnalyzerResultPath.' })" if (-not $isElevated) { $errors.Add('Not running elevated - some Defender/policy data may be incomplete. Re-run in an elevated prompt for full results.') | Out-Null } # --- assemble & write ------------------------------------------------------ $snapshot = [ordered]@{ schema = 'purview-assistant/endpoint-dlp-state' schemaVersion = 1 generatedUtc = (Get-Date).ToUniversalTime().ToString('o') tool = [ordered]@{ name = 'Get-EndpointDlpState.ps1'; version = '1.0.0'; elevated = $isElevated } device = $device join = $join onboarding = $onboarding services = $services defenderAv = $defender defenderPlatformVersion = $defenderPlatformVersion dlpBinaries = $dlpBinaries cachedPolicy = $policy checks = $checks errors = @($errors) } if (-not $OutFile) { $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') $OutFile = ".\EndpointDlpState_$($env:COMPUTERNAME)_$stamp.json" } try { if (-not $Quiet) { Write-Host ' serializing snapshot to JSON...' -ForegroundColor DarkGray } # Fast: the snapshot holds only short placeholder tokens for policy content, so # ConvertTo-Json has no multi-MB strings to escape. $json = $snapshot | ConvertTo-Json -Depth 12 # Splice the real (fast-escaped) policy content in place of the tokens. foreach ($t in @($rawInjections.Keys)) { $escaped = if ($canFastEncode) { [System.Web.HttpUtility]::JavaScriptStringEncode([string]$rawInjections[$t], $true) } else { # Linear-time manual escape; ConvertTo-Json here would reintroduce the # quadratic multi-MB hang this token splice exists to avoid. $s = ([string]$rawInjections[$t]).Replace('\', '\\').Replace('"', '\"') $s = [regex]::Replace($s, '[\x00-\x1F]', { param($m) '\u{0:x4}' -f [int][char]$m.Value }) '"' + $s + '"' } $json = $json.Replace('"' + $t + '"', $escaped) } if (-not $Quiet) { Write-Host " writing $OutFile ..." -ForegroundColor DarkGray } $json | Out-File -FilePath $OutFile -Encoding utf8 $resolvedOut = (Resolve-Path -LiteralPath $OutFile).Path } catch { Add-CollectError 'write output' $_; Write-Error "Failed to write $OutFile`: $_"; return } # --- console summary ------------------------------------------------------- if (-not $Quiet) { function Write-Line($label, $status, $detail) { $color = switch ($status) { 'pass' { 'Green' } 'fail' { 'Red' } 'warn' { 'Yellow' } default { 'Gray' } } $mark = switch ($status) { 'pass' { '[ OK ]' } 'fail' { '[FAIL]' } 'warn' { '[WARN]' } default { '[ ?? ]' } } Write-Host (" {0} {1,-42} {2}" -f $mark, $label, $detail) -ForegroundColor $color } Write-Host '' Write-Host "Endpoint DLP state - $($device.hostname)" -ForegroundColor Cyan Write-Host (" {0} (build {1})" -f $device.osCaption, $device.osBuild) -ForegroundColor Gray if (-not $isElevated) { Write-Host ' ! Not elevated - results may be incomplete.' -ForegroundColor Yellow } Write-Host '' foreach ($c in $checks) { Write-Line $c.label $c.status $c.detail } Write-Host '' Write-Host "Saved snapshot: $resolvedOut" -ForegroundColor Cyan Write-Host "Open it in Purview Assistant -> Endpoint DLP (endpoint-dlp.html) for the full decoded view." -ForegroundColor Gray if ($errors.Count) { Write-Host '' Write-Host "Notes / collection errors:" -ForegroundColor Yellow foreach ($e in $errors) { Write-Host " - $e" -ForegroundColor DarkYellow } } Write-Host '' }