# GPOPolicy.ps1 # GptTmpl.inf handling and registry-based Administrative Template settings. # Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Get-GPOSecurityTemplatePath) function Read-GptTmplInf { <# .SYNOPSIS Parses a GptTmpl.inf file into a nested hashtable keyed by section name. #> param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path $Path)) { return @{} } $result = @{} $currentSection = $null # GptTmpl.inf is UTF-16LE $lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::Unicode) foreach ($line in $lines) { $line = $line.Trim() if ($line -match '^\[(.+)\]$') { $currentSection = $Matches[1] if (-not $result.Contains($currentSection)) { $result[$currentSection] = [ordered]@{} } } elseif ($line -match '^(.+?)\s*=\s*(.*)$' -and $currentSection) { $result[$currentSection][$Matches[1].Trim()] = $Matches[2].Trim() } } return $result } function ConvertTo-GptTmplInf { <# .SYNOPSIS Converts a settings hashtable into GptTmpl.inf content string. .DESCRIPTION Takes the SecurityPolicy hashtable from a settings.ps1 file and produces the INI-format content for a GptTmpl.inf file. #> param( [Parameter(Mandatory)] [hashtable]$Settings ) $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('[Unicode]') [void]$sb.AppendLine('Unicode=yes') # Ordered section output $sectionOrder = @( 'System Access' 'Kerberos Policy' 'Event Audit' 'Registry Values' 'Privilege Rights' 'Group Membership' ) foreach ($section in $sectionOrder) { if ($Settings.Contains($section)) { [void]$sb.AppendLine("[$section]") foreach ($key in $Settings[$section].Keys) { [void]$sb.AppendLine("$key = $($Settings[$section][$key])") } } } # Include any sections not in the predefined order foreach ($section in $Settings.Keys) { if ($section -notin $sectionOrder -and $section -ne 'Unicode') { [void]$sb.AppendLine("[$section]") foreach ($key in $Settings[$section].Keys) { [void]$sb.AppendLine("$key = $($Settings[$section][$key])") } } } [void]$sb.AppendLine('[Version]') [void]$sb.AppendLine('signature="$CHICAGO$"') [void]$sb.AppendLine('Revision=1') return $sb.ToString() } function Set-GPOSecurityPolicy { <# .SYNOPSIS Writes security policy settings to a GPO's GptTmpl.inf in SYSVOL and bumps the GPO version number in AD so clients pick up the change. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [hashtable]$SecurityPolicy, [string]$Domain = (Get-ADDomain).DNSRoot ) $infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain $infDir = Split-Path $infPath -Parent # Ensure directory structure exists if (-not (Test-Path $infDir)) { New-Item -ItemType Directory -Path $infDir -Force | Out-Null } # Generate and write the inf content as UTF-16LE (required by Windows) $content = ConvertTo-GptTmplInf -Settings $SecurityPolicy [System.IO.File]::WriteAllText($infPath, $content, [System.Text.Encoding]::Unicode) Write-Host " Written: $infPath" -ForegroundColor Green } function Compare-GPOSecurityPolicy { <# .SYNOPSIS Compares desired security policy settings against what's currently in the GPO's GptTmpl.inf. Returns differences. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [hashtable]$SecurityPolicy, [string]$Domain = (Get-ADDomain).DNSRoot ) $infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain $current = Read-GptTmplInf -Path $infPath $differences = @() foreach ($section in $SecurityPolicy.Keys) { foreach ($key in $SecurityPolicy[$section].Keys) { $desired = [string]$SecurityPolicy[$section][$key] $actual = $null if ($current.Contains($section) -and $current[$section].Contains($key)) { $actual = [string]$current[$section][$key] } if ($actual -ne $desired) { $differences += [PSCustomObject]@{ Section = $section Setting = $key Current = if ($null -eq $actual) { '(not set)' } else { $actual } Desired = $desired } } } } return $differences } # ------------------------------------------------------------------- # Restricted Groups ([Group Membership] section in GptTmpl.inf) # ------------------------------------------------------------------- function ConvertTo-RestrictedGroupEntries { <# .SYNOPSIS Converts a friendly RestrictedGroups hashtable into [Group Membership] key-value pairs for GptTmpl.inf. .DESCRIPTION GptTmpl.inf [Group Membership] uses SID-based keys: *S-1-5-32-544__Members = *S-1-5-21-xxx,*S-1-5-21-yyy *S-1-5-32-544__Memberof = This function resolves group/account names to SIDs automatically. #> param( [Parameter(Mandatory)] [hashtable]$RestrictedGroups ) $entries = [ordered]@{} foreach ($groupName in $RestrictedGroups.Keys) { $groupDef = $RestrictedGroups[$groupName] # Resolve target group to SID try { $ntAccount = New-Object System.Security.Principal.NTAccount($groupName) $groupSid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { throw "RestrictedGroups: cannot resolve group '$groupName' to a SID: $_" } # Members $memberSids = @() if ($groupDef.Members) { foreach ($member in $groupDef.Members) { try { $memberNt = New-Object System.Security.Principal.NTAccount($member) $memberSid = $memberNt.Translate([System.Security.Principal.SecurityIdentifier]).Value $memberSids += "*$memberSid" } catch { throw "RestrictedGroups: cannot resolve member '$member' of group '$groupName' to a SID: $_" } } } $entries["*${groupSid}__Members"] = $memberSids -join ',' # Memberof $memberofSids = @() if ($groupDef.Memberof) { foreach ($parent in $groupDef.Memberof) { try { $parentNt = New-Object System.Security.Principal.NTAccount($parent) $parentSid = $parentNt.Translate([System.Security.Principal.SecurityIdentifier]).Value $memberofSids += "*$parentSid" } catch { throw "RestrictedGroups: cannot resolve parent group '$parent' of '$groupName' to a SID: $_" } } } $entries["*${groupSid}__Memberof"] = $memberofSids -join ',' } return $entries } # ------------------------------------------------------------------- # Registry-Based Settings (Administrative Templates) # ------------------------------------------------------------------- function Compare-GPORegistrySettings { <# .SYNOPSIS Compares desired registry-based (Administrative Template) settings against the current values in a GPO. Returns differences. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [array]$RegistrySettings, [string]$Domain = (Get-ADDomain).DNSRoot ) $differences = @() foreach ($reg in $RegistrySettings) { $desiredDisplay = "$($reg.Value) ($($reg.Type))" try { $current = Get-GPRegistryValue ` -Name $GPOName ` -Domain $Domain ` -Key $reg.Key ` -ValueName $reg.ValueName ` -ErrorAction Stop $actualValue = $current.Value $actualType = $current.Type.ToString() if ([string]$actualValue -ne [string]$reg.Value -or $actualType -ne $reg.Type) { $differences += [PSCustomObject]@{ Key = $reg.Key ValueName = $reg.ValueName Current = "$actualValue ($actualType)" Desired = $desiredDisplay } } } catch { # Setting doesn't exist in the GPO yet $differences += [PSCustomObject]@{ Key = $reg.Key ValueName = $reg.ValueName Current = '(not set)' Desired = $desiredDisplay } } } # --- Stale value detection --- # For each unique key in settings, check if the GPO has values not in the declared set $uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique $declaredLookup = @{} foreach ($reg in $RegistrySettings) { $declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true } foreach ($key in $uniqueKeys) { try { $currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain ` -Key $key -ErrorAction Stop } catch { continue } foreach ($val in $currentValues) { if (-not $val.ValueName) { continue } # skip subkey entries $lookupKey = "$key|$($val.ValueName)" if (-not $declaredLookup.ContainsKey($lookupKey)) { $differences += [PSCustomObject]@{ Key = $key ValueName = $val.ValueName Current = "$($val.Value) ($($val.Type))" Desired = '(stale -- should be removed)' } } } } return $differences } function Set-GPORegistrySettings { <# .SYNOPSIS Applies registry-based (Administrative Template) settings to a GPO using Set-GPRegistryValue. With -Cleanup, removes stale values under managed keys that are not in the declared settings. #> param( [Parameter(Mandatory)] [string]$GPOName, [Parameter(Mandatory)] [array]$RegistrySettings, [switch]$Cleanup, [string]$Domain = (Get-ADDomain).DNSRoot ) foreach ($reg in $RegistrySettings) { Set-GPRegistryValue ` -Name $GPOName ` -Domain $Domain ` -Key $reg.Key ` -ValueName $reg.ValueName ` -Type $reg.Type ` -Value $reg.Value | Out-Null Write-Host " Set: $($reg.Key)\$($reg.ValueName) = $($reg.Value)" -ForegroundColor Green } # Remove stale values under managed keys if ($Cleanup) { $declaredLookup = @{} foreach ($reg in $RegistrySettings) { $declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true } $uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique foreach ($key in $uniqueKeys) { try { $currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain ` -Key $key -ErrorAction Stop } catch { continue } foreach ($val in $currentValues) { if (-not $val.ValueName) { continue } $lookupKey = "$key|$($val.ValueName)" if (-not $declaredLookup.ContainsKey($lookupKey)) { Remove-GPRegistryValue -Name $GPOName -Domain $Domain ` -Key $key -ValueName $val.ValueName | Out-Null Write-Host " [REMOVED] Stale: $key\$($val.ValueName)" -ForegroundColor Yellow } } } } }