policies.ps1
|
#Requires -Version 5.0 function Resolve-LGPOSource { <# .SYNOPSIS Returns metadata for a known LGPO source location. .DESCRIPTION Pure data lookup. No I/O. Centralises the "where do we get LGPO from" decision so that every other function in this module can reference it without duplicating URLs or hashes. When Microsoft moves the file or publishes a new SCT release, update the table below and have a human review the diff. This function is the single point of change for supply-chain trust. .PARAMETER Source Source identifier. Currently only 'SCT-LGPO-Standalone' is recognised. .EXAMPLE PS> Resolve-LGPOSource .LINK https://github.com/adnoctem/winkit/lib/policies.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false)] [ValidateSet('SCT-LGPO-Standalone')] [string] $Source = 'SCT-LGPO-Standalone' ) $sources = @{ 'SCT-LGPO-Standalone' = [PSCustomObject]@{ Name = 'Security Compliance Toolkit - LGPO standalone' Url = 'https://download.microsoft.com/download/8/5/C/85C25433-A1B0-4FFA-9429-7E023E7DA8D8/LGPO.zip' Sha256 = 'PLACEHOLDER_REPLACE_ON_FIRST_VENDORING' ExpectedBinaryPath = 'LGPO_30/LGPO.exe' LastVerified = '2026-06-14' } } if (-not $sources.ContainsKey($Source)) { throw "Unknown LGPO source '$Source'." } return $sources[$Source] } function Test-LGPOSourceAvailability { <# .SYNOPSIS Verifies the LGPO download URL is still reachable. .DESCRIPTION Issues a HEAD request to the URL returned by Resolve-LGPOSource. Does NOT download or verify the content - that's Install-LGPO's job. Intended for weekly CI runs. The output object is suitable for serialising to JSON and archiving as ISO supply-chain evidence ("we monitor external dependencies weekly"). .PARAMETER Source Source identifier forwarded to Resolve-LGPOSource. .EXAMPLE PS> Test-LGPOSourceAvailability .LINK https://github.com/adnoctem/winkit/lib/policies.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false)] [ValidateSet('SCT-LGPO-Standalone')] [string] $Source = 'SCT-LGPO-Standalone' ) $info = Resolve-LGPOSource -Source $Source try { $response = Invoke-WebRequest -Uri $info.Url -Method Head -UseBasicParsing -ErrorAction Stop [PSCustomObject]@{ Source = $Source Url = $info.Url Available = $true StatusCode = [int]$response.StatusCode ContentLength = $response.Headers['Content-Length'] CheckedAt = (Get-Date).ToUniversalTime() } } catch { [PSCustomObject]@{ Source = $Source Url = $info.Url Available = $false Error = $_.Exception.Message CheckedAt = (Get-Date).ToUniversalTime() } } } function Install-LGPO { <# .SYNOPSIS Downloads, verifies, and installs LGPO.exe to a controllable path. .DESCRIPTION Hash-verified install. Refuses to proceed if the SHA-256 of the downloaded archive does not match Resolve-LGPOSource's recorded hash. Idempotent: re-running when LGPO.exe is already present at the destination returns the existing path unless -Force is specified. Writing to the default destination (%ProgramData%) requires administrator elevation. A non-elevated session can specify an alternate -Destination within the user's writable scope. .PARAMETER Destination Directory where LGPO.exe ends up. Defaults to %ProgramData%\winkit\tools. Non-elevated callers should supply a user-writable path. .PARAMETER Source Source identifier forwarded to Resolve-LGPOSource. .PARAMETER Force Re-download and re-install even if LGPO.exe is already present. .EXAMPLE PS> Install-LGPO .EXAMPLE PS> Install-LGPO -Force -Verbose .EXAMPLE PS> Install-LGPO -Destination "$env:LOCALAPPDATA\winkit\tools" .LINK https://github.com/adnoctem/winkit/lib/policies.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding(SupportsShouldProcess)] [OutputType([string])] param ( [Parameter(Mandatory = $false)] [string] $Destination = (Join-Path -Path $env:ProgramData -ChildPath 'winkit\tools'), [Parameter(Mandatory = $false)] [ValidateSet('SCT-LGPO-Standalone')] [string] $Source = 'SCT-LGPO-Standalone', [Parameter(Mandatory = $false)] [switch] $Force ) $info = Resolve-LGPOSource -Source $Source $exePath = Join-Path -Path $Destination -ChildPath 'LGPO.exe' if (Test-Path -LiteralPath $exePath -PathType Leaf) { if (-not $Force) { Write-Verbose "LGPO.exe already present at '$exePath'; skipping download." return $exePath } Write-Verbose "LGPO.exe already present at '$exePath'; -Force supplied, re-downloading." } if (-not $PSCmdlet.ShouldProcess($Destination, "Install LGPO from $($info.Url)")) { return } if (-not (Read-ProcessElevation)) { Write-Error "Writing to '$Destination' requires administrator rights. Run the session elevated or supply a user-writable -Destination such as '$(Join-Path -Path $env:LOCALAPPDATA -ChildPath 'winkit\tools')'." return } $null = New-Item -Path $Destination -ItemType Directory -Force -ErrorAction SilentlyContinue $zipPath = Join-Path -Path $env:TEMP -ChildPath "LGPO_$(New-Guid).zip" $extractDir = Join-Path -Path $env:TEMP -ChildPath "LGPO_extract_$(New-Guid)" try { Write-Verbose "Downloading LGPO from $($info.Url)..." Invoke-WebRequest -Uri $info.Url -OutFile $zipPath -UseBasicParsing -ErrorAction Stop $actualHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash $expectedHash = $info.Sha256.ToUpperInvariant() if ($actualHash -ne $expectedHash) { throw "SHA-256 mismatch for $($info.Url). Expected '$expectedHash', got '$actualHash'. Refusing to install." } Write-Verbose 'SHA-256 hash matches. Extracting archive...' Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force $found = Get-ChildItem -Path $extractDir -Recurse -Filter 'LGPO.exe' | Select-Object -First 1 if (-not $found) { throw "LGPO.exe not found in archive at $($info.Url)." } Copy-Item -Path $found.FullName -Destination $exePath -Force Write-Verbose "Installed LGPO.exe to '$exePath'." } finally { if (Test-Path -LiteralPath $zipPath -PathType Leaf) { Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $extractDir -PathType Container) { Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue } } return $exePath } function Test-LGPOInstalled { <# .SYNOPSIS Returns $true if LGPO.exe is present at the expected path. .PARAMETER Path Full path to LGPO.exe. Defaults to %ProgramData%\winkit\tools\LGPO.exe. .EXAMPLE PS> if (Test-LGPOInstalled) { Invoke-LGPO -PolicyPath .\policy.txt } .LINK https://github.com/adnoctem/winkit/lib/policies.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $false)] [string] $Path = (Join-Path -Path $env:ProgramData -ChildPath 'winkit\tools\LGPO.exe') ) return (Test-Path -LiteralPath $Path -PathType Leaf) } function Invoke-LGPO { <# .SYNOPSIS Applies a policy file or GPO backup directory using LGPO.exe. .DESCRIPTION Accepts a path to either a policy text file (applied via /t) or a directory containing a GPO backup (applied via /g). The argument is auto-detected based on whether PolicyPath is a file or directory. Returns a structured object describing the apply, suitable for logging as audit evidence. .PARAMETER PolicyPath Path to a policy text file or a directory containing a GPO backup. .PARAMETER LgpoExe Path to LGPO.exe. Defaults to %ProgramData%\winkit\tools\LGPO.exe. .EXAMPLE PS> Invoke-LGPO -PolicyPath .\resources\policies\01-telemetry.txt .LINK https://github.com/adnoctem/winkit/lib/policies.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true)] [ValidateScript({ Test-Path -LiteralPath $_ })] [string] $PolicyPath, [Parameter(Mandatory = $false)] [string] $LgpoExe = (Join-Path -Path $env:ProgramData -ChildPath 'winkit\tools\LGPO.exe') ) if (-not (Test-Path -LiteralPath $LgpoExe -PathType Leaf)) { throw "LGPO.exe not found at '$LgpoExe'. Run Install-LGPO first." } $isDirectory = Test-Path -LiteralPath $PolicyPath -PathType Container $arg = if ($isDirectory) { '/g' } else { '/t' } if (-not $PSCmdlet.ShouldProcess($PolicyPath, "Apply via LGPO.exe ($arg)")) { return } $stdoutFile = Join-Path -Path $env:TEMP -ChildPath "lgpo_stdout_$(New-Guid).log" $stderrFile = Join-Path -Path $env:TEMP -ChildPath "lgpo_stderr_$(New-Guid).log" try { $proc = Start-Process -FilePath $LgpoExe ` -ArgumentList @($arg, $PolicyPath) ` -RedirectStandardOutput $stdoutFile ` -RedirectStandardError $stderrFile ` -Wait -NoNewWindow -PassThru [PSCustomObject]@{ PolicyPath = $PolicyPath Mode = if ($isDirectory) { 'GpoBackup' } else { 'TextSource' } ExitCode = $proc.ExitCode StdOut = (Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction SilentlyContinue) StdErr = (Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue) AppliedAt = (Get-Date).ToUniversalTime() Success = ($proc.ExitCode -eq 0) } } finally { if (Test-Path -LiteralPath $stdoutFile -PathType Leaf) { Remove-Item -LiteralPath $stdoutFile -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $stderrFile -PathType Leaf) { Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue } } } # Binary codec helpers are private; only the two converters are exported. function Read-PSFPolicyString { param ([System.IO.BinaryReader]$Reader) $text = [Text.StringBuilder]::new() while ($true) { $character = $Reader.ReadUInt16() if ($character -eq 0) { return $text.ToString() } if ($text.Length -ge 32767) { throw 'Policy identifier exceeds 32767 characters.' } [void]$text.Append([char]$character) } } function Assert-PSFPolicyDelimiter { param ([System.IO.BinaryReader]$Reader, [char]$Expected) if ($Reader.ReadUInt16() -ne [uint16]$Expected) { throw "Expected policy delimiter '$Expected'." } } function ConvertFrom-PSFPolicyPayload { param ([uint32]$Type, [byte[]]$Bytes) if ($Bytes.Length -eq 0) { return $null } switch ($Type) { { $_ -in 1, 2, 7 } { if ($Bytes.Length % 2 -ne 0) { throw 'String payload has an odd byte count.' } $encoding = [Text.UnicodeEncoding]::new($false, $false, $true) $text = $encoding.GetString($Bytes) if (-not $text.EndsWith([string][char]0)) { throw 'String payload is not null terminated.' } if ($Type -eq 7) { if (-not $text.EndsWith(([string][char]0) * 2)) { throw 'MULTI_SZ requires two terminating nulls.' } $text = $text.Substring(0, $text.Length - 2) if ($text.Length -eq 0) { return , ([string[]]@()) } return , ([string[]]$text.Split([char]0)) } return $text.Substring(0, $text.Length - 1) } 4 { if ($Bytes.Length -ne 4) { throw 'DWORD payload must contain four bytes.' } return [BitConverter]::ToUInt32($Bytes, 0) } 5 { if ($Bytes.Length -ne 4) { throw 'DWORD_BIG_ENDIAN payload must contain four bytes.' } $copy = [byte[]]$Bytes.Clone() [array]::Reverse($copy) return [BitConverter]::ToUInt32($copy, 0) } 11 { if ($Bytes.Length -ne 8) { throw 'QWORD payload must contain eight bytes.' } return [BitConverter]::ToUInt64($Bytes, 0) } default { return , $Bytes } } } function ConvertTo-PSFPolicyPayload { param ([uint32]$Type, [AllowNull()][object]$Data, [switch]$Raw) if ($null -eq $Data) { return , ([byte[]]@()) } if ($Raw -or $Type -notin 1, 2, 4, 5, 7, 11) { if ($Data -isnot [byte[]]) { throw 'Raw and opaque policy data must be a byte array.' } return , $Data } switch ($Type) { { $_ -in 1, 2 } { if ($Data -isnot [string] -or $Data.Contains([string][char]0)) { throw 'SZ and EXPAND_SZ data must be a string without embedded nulls.' } return , ([Text.Encoding]::Unicode.GetBytes($Data + [char]0)) } 7 { foreach ($item in $Data) { if ($item -isnot [string] -or $item.Length -eq 0 -or $item.Contains([string][char]0)) { throw 'MULTI_SZ data must contain nonempty strings without embedded nulls.' } } return , ([Text.Encoding]::Unicode.GetBytes(($Data -join [char]0) + ([string][char]0) * 2)) } { $_ -in 4, 5, 11 } { # Avoid PowerShell silently rounding fractional numbers during a cast. $integerText = [Convert]::ToString($Data, [Globalization.CultureInfo]::InvariantCulture) if ($integerText -notmatch '^\d+$') { throw 'Integer policy data must be an unsigned whole number.' } if ($Type -eq 11) { return , ([BitConverter]::GetBytes([uint64]::Parse($integerText))) } $bytes = [BitConverter]::GetBytes([uint32]::Parse($integerText)) if ($Type -eq 5) { [array]::Reverse($bytes) } return , $bytes } } } function ConvertFrom-RegistryPolicy { <# .SYNOPSIS Reads registry.pol records without applying policy or requiring LGPO. .DESCRIPTION Validates a PReg version 1 binary file and emits records in file order, retaining duplicates and special policy directives. Keys are relative to a hive; the file's Machine/User location determines that hive. Type is a numeric registry type. Data is a string, string array, UInt32, UInt64, or byte array. Zero-length data is null. Unknown types remain opaque bytes. EXPAND_SZ variables are not expanded. Files are limited to 64 MiB and individual payloads to 65535 bytes. Malformed files terminate with path and byte-offset information; no partial records are emitted. .PARAMETER Path Literal filesystem path to a registry.pol file. .PARAMETER Raw Return every payload as bytes, including empty arrays, tagged with the PSFoundation.RegistryPolicy.RawEntry type name. The writer recognizes this type for lossless binary round trips, including opaque data. .EXAMPLE PS> ConvertFrom-RegistryPolicy -Path '.\Machine\registry.pol' .EXAMPLE PS> ConvertFrom-RegistryPolicy -Path '.\source.pol' -Raw | ConvertTo-RegistryPolicy -Path '.\copy.pol' .OUTPUTS PSCustomObject with Key, ValueName, Type, and Data properties. .LINK https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpreg/5c092c22-bf6b-4e7f-b180-b20743d368f5 #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true, Position = 0)] [string]$Path, [switch]$Raw ) $filePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) $stream = [IO.File]::OpenRead($filePath) $reader = [IO.BinaryReader]::new($stream) $entries = [Collections.Generic.List[object]]::new() try { if ($stream.Length -gt 64MB) { throw 'Policy file exceeds the 64 MiB limit.' } if ($reader.ReadUInt32() -ne 0x67655250) { throw 'Invalid PReg signature.' } if ($reader.ReadUInt32() -ne 1) { throw 'Unsupported PReg version (expected 1).' } while ($stream.Position -lt $stream.Length) { Assert-PSFPolicyDelimiter -Reader $reader -Expected '[' $key = Read-PSFPolicyString -Reader $reader if ([string]::IsNullOrEmpty($key)) { throw 'Policy key must not be empty.' } Assert-PSFPolicyDelimiter -Reader $reader -Expected ';' $name = Read-PSFPolicyString -Reader $reader Assert-PSFPolicyDelimiter -Reader $reader -Expected ';' $type = $reader.ReadUInt32() Assert-PSFPolicyDelimiter -Reader $reader -Expected ';' $size = $reader.ReadUInt32() Assert-PSFPolicyDelimiter -Reader $reader -Expected ';' if ($size -gt 65535 -or $size -gt ($stream.Length - $stream.Position - 2)) { throw 'Invalid or truncated policy payload size.' } $bytes = $reader.ReadBytes([int]$size) Assert-PSFPolicyDelimiter -Reader $reader -Expected ']' $entry = [PSCustomObject]@{ Key = $key; ValueName = $name; Type = $type; Data = $bytes } if ($Raw) { $entry.PSObject.TypeNames.Insert(0, 'PSFoundation.RegistryPolicy.RawEntry') } else { $entry.Data = ConvertFrom-PSFPolicyPayload -Type $type -Bytes $bytes $entry.PSObject.TypeNames.Insert(0, 'PSFoundation.RegistryPolicy.Entry') } $entries.Add($entry) } } catch { throw [IO.InvalidDataException]::new("Invalid registry policy '$filePath' at byte $($stream.Position): $($_.Exception.Message)", $_.Exception) } finally { $reader.Dispose() } $entries.ToArray() } function ConvertTo-RegistryPolicy { <# .SYNOPSIS Writes ordered records to a registry.pol file without applying policy. .DESCRIPTION Accepts records with Key, ValueName, numeric Type, and Data. Supports decoded records and RawEntry records from ConvertFrom-RegistryPolicy. Preserves order, duplicates, and directive names. Raw entries retain payload bytes; other records are encoded by registry type. Null data encodes a zero-byte payload. Unknown types require byte arrays. Validates and serializes the entire input before creating a temporary sibling file and atomically replacing the destination. Empty input writes a header-only file. Parent directories must already exist. Limits match the reader: 64 MiB per file, 65535 bytes per payload. .PARAMETER InputObject One record or an array of records, also accepted from the pipeline. .PARAMETER Path Literal filesystem destination. Machine/User scope is chosen by the caller. .PARAMETER Force Allow replacement of an existing destination file. .EXAMPLE PS> $entries | ConvertTo-RegistryPolicy -Path '.\registry.pol' -Force -WhatIf .EXAMPLE PS> ConvertTo-RegistryPolicy -InputObject @() -Path '.\empty.pol' .OUTPUTS None. Writes the destination only after validation and ShouldProcess. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([void])] param ( [Parameter(ValueFromPipeline = $true)] [AllowEmptyCollection()] [object[]]$InputObject = @(), [Parameter(Mandatory = $true)] [string]$Path, [switch]$Force ) begin { $records = [Collections.Generic.List[object]]::new() } process { foreach ($record in $InputObject) { $records.Add($record) } } end { $filePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) if ([IO.File]::Exists($filePath) -and -not $Force) { throw "Destination '$filePath' exists. Use -Force to replace it." } $buffer = [IO.MemoryStream]::new() $writer = [IO.BinaryWriter]::new($buffer) $temporaryPath = $null try { $writer.Write([uint32]0x67655250) $writer.Write([uint32]1) foreach ($record in $records) { if ($null -eq $record) { throw 'Policy records must not be null.' } $entry = [PSCustomObject]$record foreach ($property in @('Key', 'ValueName', 'Type', 'Data')) { if ($null -eq $entry.PSObject.Properties[$property]) { throw "Policy record is missing '$property'." } } if ($entry.Key -isnot [string] -or [string]::IsNullOrEmpty($entry.Key) -or $entry.Key -match '^(HKLM|HKCU|HKEY_LOCAL_MACHINE|HKEY_CURRENT_USER)(:|\\|$)') { throw 'Policy Key must be a nonempty path relative to the registry hive.' } if ($entry.ValueName -isnot [string]) { throw 'Policy ValueName must be a string (empty is allowed).' } foreach ($identifier in @($entry.Key, $entry.ValueName)) { if ($identifier.Length -gt 32767 -or $identifier.Contains([string][char]0)) { throw 'Policy identifiers must not contain nulls or exceed 32767 characters.' } } if ([string]$entry.Type -notmatch '^\d+$') { throw 'Policy Type must be an unsigned integer.' } $type = [uint32]$entry.Type $isRaw = $entry.PSObject.TypeNames -contains 'PSFoundation.RegistryPolicy.RawEntry' $bytes = ConvertTo-PSFPolicyPayload -Type $type -Data $entry.Data -Raw:$isRaw if ($bytes.Length -gt 65535) { throw 'Policy payload exceeds 65535 bytes.' } $recordSize = 24L + 2L * ($entry.Key.Length + $entry.ValueName.Length) + $bytes.Length if ($buffer.Length + $recordSize -gt 64MB) { throw 'Policy file exceeds the 64 MiB limit.' } $writer.Write([uint16][char]'[') $writer.Write([Text.Encoding]::Unicode.GetBytes($entry.Key + [char]0)) $writer.Write([uint16][char]';') $writer.Write([Text.Encoding]::Unicode.GetBytes($entry.ValueName + [char]0)) $writer.Write([uint16][char]';') $writer.Write($type) $writer.Write([uint16][char]';') $writer.Write([uint32]$bytes.Length) $writer.Write([uint16][char]';') $writer.Write([byte[]]$bytes) $writer.Write([uint16][char]']') } $writer.Flush() if (-not $PSCmdlet.ShouldProcess($filePath, 'Write registry policy file')) { return } $temporaryPath = Join-Path ([IO.Path]::GetDirectoryName($filePath)) ([IO.Path]::GetRandomFileName()) $output = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $buffer.Position = 0 $buffer.CopyTo($output) $output.Flush() } finally { $output.Dispose() } if ($Force -and [IO.File]::Exists($filePath)) { # PowerShell converts $null to an empty string for this .NET overload. [IO.File]::Replace($temporaryPath, $filePath, [NullString]::Value) } else { [IO.File]::Move($temporaryPath, $filePath) } } finally { $writer.Dispose() if ($temporaryPath -and [IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) } } } } |