# GPOPreferences.ps1 # Group Policy Preferences XML generation for 10 types. # Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids) $Script:GppToolGuid = '{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}' $Script:GppActionCode = @{ Create = 'C'; Replace = 'R'; Update = 'U'; Delete = 'D' } $Script:GppActionImage = @{ Create = '0'; Replace = '1'; Update = '2'; Delete = '3' } $Script:GppTypeInfo = @{ ScheduledTasks = @{ RootClsid = '{CC63F200-7309-4ba0-B154-A71CD118DBCC}' ItemClsid = '{D8896631-B747-47a7-84A6-C155337F3BC8}' SysvolDir = 'ScheduledTasks' FileName = 'ScheduledTasks.xml' CseGuid = '{AADCED64-746C-4633-A97C-D61349046527}' NameKey = 'Name' } DriveMaps = @{ RootClsid = '{8FDDCC1A-0C3C-43cd-A6B4-71A6DF20DA8C}' ItemClsid = '{935D1B74-9CB8-4e3c-9914-7DD559B7A417}' SysvolDir = 'Drives' FileName = 'Drives.xml' CseGuid = '{5794DAFD-BE60-433f-88A2-1A31939AC01F}' NameKey = 'Letter' } EnvironmentVariables = @{ RootClsid = '{D76B9641-3288-4f75-942D-087DE603E3EA}' ItemClsid = '{B1A3FA3F-E5D1-41ea-88D1-55CE96B6C78B}' SysvolDir = 'EnvironmentVariables' FileName = 'EnvironmentVariables.xml' CseGuid = '{0E28E245-9368-4853-AD84-6DA3BA35BB75}' NameKey = 'Name' } Services = @{ RootClsid = '{2CFB484A-4E86-4eb1-8B6A-E1535488BDBF}' ItemClsid = '{AB6F0B67-D4E1-4f59-A9E5-F6BD3A72F4FF}' SysvolDir = 'Services' FileName = 'Services.xml' CseGuid = '{91FBB303-0CD5-4055-BF42-E512A681B325}' NameKey = 'ServiceName' } Printers = @{ RootClsid = '{1F577D12-3D1B-471e-A1B7-060317597B9C}' ItemClsid = '{9A5E9697-9095-436d-A0EE-4D128FDFBCE5}' SysvolDir = 'Printers' FileName = 'Printers.xml' CseGuid = '{A8C42CEA-CDB8-4388-97F4-5831F933DA84}' NameKey = 'Path' } Shortcuts = @{ RootClsid = '{872ECB34-B2EC-401b-A585-D32574AA90EE}' ItemClsid = '{4F2F7C55-2790-433e-8127-0739D1CFA327}' SysvolDir = 'Shortcuts' FileName = 'Shortcuts.xml' CseGuid = '{C418DD9D-0D14-4EFB-8FBF-CFE535C8FAC7}' NameKey = 'Name' } Files = @{ RootClsid = '{215B2E53-57CE-475c-80FE-9EEC14635851}' ItemClsid = '{50BE44C8-567A-4ed1-B1D0-9234FE1F38AF}' SysvolDir = 'Files' FileName = 'Files.xml' CseGuid = '{7150F9BF-48AD-4DA4-A49C-29EF4A8369BA}' NameKey = 'TargetPath' } NetworkShares = @{ RootClsid = '{520870D8-A6E7-47e8-A8D8-E6A4E76EAEC2}' ItemClsid = '{2888C5E7-94FC-4739-90AA-2C1536D68BC0}' SysvolDir = 'NetworkShares' FileName = 'NetworkShares.xml' CseGuid = '{6A4C88C6-C502-4F74-8F60-2CB23EDC24E2}' NameKey = 'Name' } RegistryItems = @{ RootClsid = '{A3CCFC41-DFDB-43a5-8D26-0FE8B954DA51}' ItemClsid = '{9CD4B2F4-923D-47f5-A062-E897DD1DAD50}' SysvolDir = 'Registry' FileName = 'Registry.xml' CseGuid = '{B087BE9D-ED37-454F-AF9C-04291E351182}' NameKey = 'Name' } LocalUsersAndGroups = @{ RootClsid = '{3125E937-EB16-4b4c-9934-544FC6D24D26}' ItemClsid = '{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}' SysvolDir = 'Groups' FileName = 'Groups.xml' CseGuid = '{17D89FEC-5C44-4972-B12D-241CAEF74509}' NameKey = 'GroupName' } } function ConvertTo-ILTFilterXml { <# .SYNOPSIS Converts a Filters array from a GPP item into the ILT XML block. Returns empty string if no filters are defined. .DESCRIPTION Supports SecurityGroup, OrgUnit, Computer, User, OperatingSystem, and WMI filter types. SecurityGroup filters resolve names to SIDs at runtime. #> param( [array]$Filters ) if (-not $Filters -or $Filters.Count -eq 0) { return '' } $esc = [System.Security.SecurityElement] $filterXml = foreach ($f in $Filters) { $bool = if ($f.Bool) { $f.Bool } else { 'AND' } $not = if ($f.Not) { '1' } else { '0' } $name = $esc::Escape($f.Name) switch ($f.Type) { 'SecurityGroup' { try { $ntAccount = New-Object System.Security.Principal.NTAccount($f.Name) $sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { throw "ILT Filter: cannot resolve group '$($f.Name)' to a SID: $_" } $userContext = if ($f.ContainsKey('UserContext') -and -not $f.UserContext) { '0' } else { '1' } $primaryGroup = if ($f.PrimaryGroup) { '1' } else { '0' } $localGroup = if ($f.LocalGroup) { '1' } else { '0' } " " } 'OrgUnit' { $type = if ($f.ContainsKey('OUType')) { $f.OUType } else { '' } " " } 'Computer' { $type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' } " " } 'User' { $type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' } " " } 'OperatingSystem' { $edition = if ($f.Edition) { $esc::Escape($f.Edition) } else { '' } $version = if ($f.Version) { $esc::Escape($f.Version) } else { '' } " " } 'WMI' { $property = if ($f.Property) { $esc::Escape($f.Property) } else { '' } $namespace = if ($f.Namespace) { $esc::Escape($f.Namespace) } else { 'root\CIMv2' } $query = $esc::Escape($f.Query) " " } default { Write-Host " [WARN] Unknown ILT filter type: $($f.Type)" -ForegroundColor Yellow $null } } } $validFilters = @($filterXml | Where-Object { $_ }) if ($validFilters.Count -eq 0) { return '' } return "`n `n$($validFilters -join "`n")`n " } function ConvertTo-ScheduledTaskXml { <# .SYNOPSIS Generates ScheduledTasks.xml GPP content for TaskV2 entries. #> param( [Parameter(Mandatory)] [array]$Tasks, [string]$Scope = 'Machine' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $userContext = if ($Scope -eq 'User') { '1' } else { '0' } $itemsXml = foreach ($task in $Tasks) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$task.Action] $image = $Script:GppActionImage[$task.Action] $name = $esc::Escape($task.Name) $runAs = if ($task.RunAs) { $task.RunAs } else { 'NT AUTHORITY\System' } $command = $esc::Escape($task.Command) $arguments = if ($task.Arguments) { $esc::Escape($task.Arguments) } else { '' } $trigger = switch ($task.Trigger) { 'AtStartup' { 'true' } 'AtLogon' { 'true' } default { 'true' } } $filterBlock = ConvertTo-ILTFilterXml -Filters $task.Filters @" $runAs S4U HighestAvailable PT10M PT1H true false IgnoreNew false false true true true true false PT72H 7 $trigger $command $arguments $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-DriveMapXml { <# .SYNOPSIS Generates Drives.xml GPP content for drive map entries. #> param( [Parameter(Mandatory)] [array]$Drives ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $itemsXml = foreach ($drive in $Drives) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$drive.Action] $image = $Script:GppActionImage[$drive.Action] $letter = $drive.Letter.TrimEnd(':') $path = $esc::Escape($drive.Path) $label = if ($drive.Label) { $esc::Escape($drive.Label) } else { '' } $persistent = if ($drive.Reconnect) { '1' } else { '0' } $filterBlock = ConvertTo-ILTFilterXml -Filters $drive.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-EnvironmentVariableXml { <# .SYNOPSIS Generates EnvironmentVariables.xml GPP content. #> param( [Parameter(Mandatory)] [array]$Variables, [string]$Scope = 'Machine' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $userContext = if ($Scope -eq 'User') { '1' } else { '0' } $user = if ($Scope -eq 'User') { '1' } else { '0' } $itemsXml = foreach ($var in $Variables) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$var.Action] $image = $Script:GppActionImage[$var.Action] $name = $esc::Escape($var.Name) $value = $esc::Escape($var.Value) $filterBlock = ConvertTo-ILTFilterXml -Filters $var.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-ServiceXml { <# .SYNOPSIS Generates Services.xml GPP content for NTService entries. #> param( [Parameter(Mandatory)] [array]$Services ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $startupMap = @{ Automatic = 'AUTOMATIC' Manual = 'MANUAL' Disabled = 'DISABLED' } $itemsXml = foreach ($svc in $Services) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$svc.Action] $image = $Script:GppActionImage[$svc.Action] $serviceName = $esc::Escape($svc.ServiceName) $startupType = $startupMap[$svc.StartupType] if (-not $startupType) { $startupType = 'NOCHANGE' } $filterBlock = ConvertTo-ILTFilterXml -Filters $svc.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-PrinterXml { <# .SYNOPSIS Generates Printers.xml GPP content for shared printer entries. #> param( [Parameter(Mandatory)] [array]$Printers ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $itemsXml = foreach ($printer in $Printers) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$printer.Action] $image = $Script:GppActionImage[$printer.Action] $path = $esc::Escape($printer.Path) # Derive name from last segment of UNC path $name = ($printer.Path -split '\\' | Where-Object { $_ })[-1] $default = if ($printer.Default) { '1' } else { '0' } $skipLocal = if ($printer.SkipLocal) { '1' } else { '0' } $comment = if ($printer.Comment) { $esc::Escape($printer.Comment) } else { '' } $location = if ($printer.Location) { $esc::Escape($printer.Location) } else { '' } $filterBlock = ConvertTo-ILTFilterXml -Filters $printer.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-ShortcutXml { <# .SYNOPSIS Generates Shortcuts.xml GPP content for shortcut entries. #> param( [Parameter(Mandatory)] [array]$Shortcuts, [string]$Scope = 'User' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $userContext = if ($Scope -eq 'User') { '1' } else { '0' } $itemsXml = foreach ($shortcut in $Shortcuts) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$shortcut.Action] $image = $Script:GppActionImage[$shortcut.Action] $name = $esc::Escape($shortcut.Name) $targetType = if ($shortcut.TargetType) { $shortcut.TargetType } else { 'FILESYSTEM' } $targetPath = $esc::Escape($shortcut.TargetPath) $shortcutPath = $esc::Escape($shortcut.ShortcutPath) $arguments = if ($shortcut.Arguments) { $esc::Escape($shortcut.Arguments) } else { '' } $startIn = if ($shortcut.StartIn) { $esc::Escape($shortcut.StartIn) } else { '' } $comment = if ($shortcut.Comment) { $esc::Escape($shortcut.Comment) } else { '' } $iconPath = if ($shortcut.IconPath) { $esc::Escape($shortcut.IconPath) } else { '' } $iconIndex = if ($shortcut.IconIndex) { $shortcut.IconIndex } else { '0' } $filterBlock = ConvertTo-ILTFilterXml -Filters $shortcut.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-FileXml { <# .SYNOPSIS Generates Files.xml GPP content for file copy/replace entries. #> param( [Parameter(Mandatory)] [array]$Files, [string]$Scope = 'Machine' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $userContext = if ($Scope -eq 'User') { '1' } else { '0' } $itemsXml = foreach ($file in $Files) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$file.Action] $image = $Script:GppActionImage[$file.Action] $fromPath = $esc::Escape($file.FromPath) $targetPath = $esc::Escape($file.TargetPath) $readOnly = if ($file.ReadOnly) { '1' } else { '0' } $hidden = if ($file.Hidden) { '1' } else { '0' } $filterBlock = ConvertTo-ILTFilterXml -Filters $file.Filters @" "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-NetworkShareXml { <# .SYNOPSIS Generates NetworkShares.xml GPP content for network share entries. #> param( [Parameter(Mandatory)] [array]$Shares ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $itemsXml = foreach ($share in $Shares) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$share.Action] $image = $Script:GppActionImage[$share.Action] $name = $esc::Escape($share.Name) $path = $esc::Escape($share.Path) $comment = if ($share.Comment) { $esc::Escape($share.Comment) } else { '' } $allRegular = if ($share.AllRegular) { $share.AllRegular } else { '' } $allHidden = if ($share.AllHidden) { $share.AllHidden } else { '' } $allAdminDrive = if ($share.AllAdminDrive) { $share.AllAdminDrive } else { '' } $limitUsers = if ($share.LimitUsers) { $share.LimitUsers } else { '0' } $filterBlock = ConvertTo-ILTFilterXml -Filters $share.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-RegistryItemXml { <# .SYNOPSIS Generates Registry.xml GPP content for registry preference items. These are GPP Registry items (not Administrative Templates). #> param( [Parameter(Mandatory)] [array]$Items, [string]$Scope = 'Machine' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $userContext = if ($Scope -eq 'User') { '1' } else { '0' } $itemsXml = foreach ($item in $Items) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$item.Action] $image = $Script:GppActionImage[$item.Action] $hive = $esc::Escape($item.Hive) $key = $esc::Escape($item.Key) $name = if ($item.Name) { $esc::Escape($item.Name) } else { '' } $type = if ($item.Type) { $item.Type } else { 'REG_SZ' } $value = if ($item.Value) { $esc::Escape($item.Value) } else { '' } $displayName = if ($name) { $name } else { '(Default)' } $filterBlock = ConvertTo-ILTFilterXml -Filters $item.Filters @" $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function ConvertTo-LocalGroupXml { <# .SYNOPSIS Generates Groups.xml GPP content for local user/group management. Supports ADD/REMOVE individual members (unlike RestrictedGroups which enforces exact membership). #> param( [Parameter(Mandatory)] [array]$Groups ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $esc = [System.Security.SecurityElement] $itemsXml = foreach ($group in $Groups) { $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" $action = $Script:GppActionCode[$group.Action] $image = $Script:GppActionImage[$group.Action] $groupName = $esc::Escape($group.GroupName) $newName = if ($group.NewName) { $esc::Escape($group.NewName) } else { '' } $description = if ($group.Description) { $esc::Escape($group.Description) } else { '' } $deleteAllUsers = if ($group.DeleteAllUsers) { '1' } else { '0' } $deleteAllGroups = if ($group.DeleteAllGroups) { '1' } else { '0' } $removeAccounts = if ($group.RemoveAccounts) { '1' } else { '0' } # Build block $membersXml = '' if ($group.Members -and $group.Members.Count -gt 0) { $memberLines = foreach ($m in $group.Members) { $memberName = $esc::Escape($m.Name) $memberAction = $m.Action.ToUpper() $sid = '' try { $ntAccount = New-Object System.Security.Principal.NTAccount($m.Name) $sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { Write-Host " [WARN] Cannot resolve '$($m.Name)' to SID -- leaving empty" -ForegroundColor Yellow } " " } $membersXml = "`n `n$($memberLines -join "`n")`n " } $filterBlock = ConvertTo-ILTFilterXml -Filters $group.Filters @" $membersXml $filterBlock "@ } return @" $($itemsXml -join "`n") "@ } function Set-GPOPreferences { <# .SYNOPSIS Writes Group Policy Preferences XML files to SYSVOL. Supports 10 GPP types: ScheduledTasks, DriveMaps, EnvironmentVariables, Services, Printers, Shortcuts, Files, NetworkShares, RegistryItems, LocalUsersAndGroups. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [hashtable]$Preferences, [string]$Domain = (Get-ADDomain).DNSRoot ) $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain $utf8Bom = [System.Text.UTF8Encoding]::new($true) foreach ($typeName in $Preferences.Keys) { $typeInfo = $Script:GppTypeInfo[$typeName] if (-not $typeInfo) { Write-Host " [WARN] Unknown preference type: $typeName" -ForegroundColor Yellow continue } $items = $Preferences[$typeName] if (-not $items -or $items.Count -eq 0) { continue } # Group items by scope $byScope = @{ Machine = @(); User = @() } foreach ($item in $items) { $scope = switch ($typeName) { 'DriveMaps' { 'User' } 'Printers' { 'User' } 'Services' { 'Machine' } 'NetworkShares' { 'Machine' } 'LocalUsersAndGroups' { 'Machine' } default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } } } $byScope[$scope] += $item } foreach ($scope in @('Machine', 'User')) { $scopeItems = $byScope[$scope] if ($scopeItems.Count -eq 0) { continue } # Generate XML $xml = switch ($typeName) { 'ScheduledTasks' { ConvertTo-ScheduledTaskXml -Tasks $scopeItems -Scope $scope } 'DriveMaps' { ConvertTo-DriveMapXml -Drives $scopeItems } 'EnvironmentVariables' { ConvertTo-EnvironmentVariableXml -Variables $scopeItems -Scope $scope } 'Services' { ConvertTo-ServiceXml -Services $scopeItems } 'Printers' { ConvertTo-PrinterXml -Printers $scopeItems } 'Shortcuts' { ConvertTo-ShortcutXml -Shortcuts $scopeItems -Scope $scope } 'Files' { ConvertTo-FileXml -Files $scopeItems -Scope $scope } 'NetworkShares' { ConvertTo-NetworkShareXml -Shares $scopeItems } 'RegistryItems' { ConvertTo-RegistryItemXml -Items $scopeItems -Scope $scope } 'LocalUsersAndGroups' { ConvertTo-LocalGroupXml -Groups $scopeItems } } # Write to SYSVOL $prefDir = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)" if (-not (Test-Path $prefDir)) { New-Item -ItemType Directory -Path $prefDir -Force | Out-Null } $xmlPath = Join-Path $prefDir $typeInfo.FileName [System.IO.File]::WriteAllText($xmlPath, $xml, $utf8Bom) Write-Host " Written: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName) ($($scopeItems.Count) item(s))" -ForegroundColor Green # Register CSE extension GUIDs Add-GPOExtensionGuids -GPOName $GPOName -CseGuid $typeInfo.CseGuid -ToolGuid $Script:GppToolGuid -Scope $scope -Domain $Domain } } } function Compare-GPOPreferences { <# .SYNOPSIS Compares desired Group Policy Preferences against what's currently deployed in SYSVOL. Checks XML file existence and item presence by name. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [hashtable]$Preferences, [string]$Domain = (Get-ADDomain).DNSRoot ) $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain $diffs = @() Write-Host " Comparing preferences..." -ForegroundColor Yellow foreach ($typeName in $Preferences.Keys) { $typeInfo = $Script:GppTypeInfo[$typeName] if (-not $typeInfo) { continue } $items = $Preferences[$typeName] if (-not $items -or $items.Count -eq 0) { continue } # Group items by scope $byScope = @{ Machine = @(); User = @() } foreach ($item in $items) { $scope = switch ($typeName) { 'DriveMaps' { 'User' } 'Printers' { 'User' } 'Services' { 'Machine' } 'NetworkShares' { 'Machine' } 'LocalUsersAndGroups' { 'Machine' } default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } } } $byScope[$scope] += $item } foreach ($scope in @('Machine', 'User')) { $scopeItems = $byScope[$scope] if ($scopeItems.Count -eq 0) { continue } $xmlPath = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)" if (-not (Test-Path $xmlPath)) { Write-Host " [DRIFT] Missing: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'Preference' PrefType = $typeName Scope = $scope Item = '(entire file)' Status = 'Missing' } continue } # Parse existing XML and check for each desired item try { [xml]$existingXml = Get-Content $xmlPath -Raw foreach ($item in $scopeItems) { $itemName = $item.($typeInfo.NameKey) # DriveMaps use "Z:" format in XML but settings use "Z" $searchName = $itemName if ($typeName -eq 'DriveMaps') { $searchName = "$($itemName.TrimEnd(':')):" } $found = $existingXml.SelectNodes("//*[@name='$searchName']") if (-not $found -or $found.Count -eq 0) { Write-Host " [DRIFT] Missing $typeName item: $itemName" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'Preference' PrefType = $typeName Scope = $scope Item = $itemName Status = 'Missing item' } } else { Write-Host " [OK] $typeName`: $itemName" -ForegroundColor Green } } } catch { Write-Host " [DRIFT] Cannot parse: $($typeInfo.FileName) -- $($_.Exception.Message)" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'Preference' PrefType = $typeName Scope = $scope Item = '(parse error)' Status = 'Invalid XML' } } } } if ($diffs.Count -eq 0) { Write-Host " [OK] All preferences match desired state" -ForegroundColor Green } else { Write-Host " [DRIFT] $($diffs.Count) preference difference(s) found" -ForegroundColor Red } return $diffs }