Modules/GPRegistryPolicyFileParser/GPRegistryPolicyFileParser.psm1

$script:localizedData = Get-LocalizedData -DefaultUICulture en-US


<#
    GetPrivateProfileString and WritePrivateProfileString are functions exposed via kernel32.dll that allow for reading and
    creating/modifying .ini files respectively. These signatures are defined below and exposed when the module is imported
    to be used in correctly configuring the gpt.ini file in order for Group Policy to be processed successfully.
    Reference:
    GetPrivateProfileString: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprivateprofilestring
    WritePrivateProfileString: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-writeprivateprofilestringa
#>


$profileStringSignature = @'
    [DllImport("kernel32.dll")]
    public static extern uint GetPrivateProfileString(
        string lpAppName,
        string lpKeyName,
        string lpDefault,
        StringBuilder lpReturnedString,
        uint nSize,
        string lpFileName
    );
 
    [DllImport("kernel32.dll")]
    public static extern bool WritePrivateProfileString(
        string lpAppName,
        string lpKeyName,
        string lpString,
        string lpFileName
    );
'@


Add-Type -MemberDefinition $profileStringSignature -Name IniUtility -Namespace GPRegistryPolicyDsc -Using System.Text

<#
    .SYNOPSIS
        Reads and parses a .pol file.
 
    .DESCRIPTION
        Reads a .pol file, parses it and returns an array of Group Policy registry settings.
 
    .PARAMETER Path
        Specifies the path to the .pol file.
 
    .EXAMPLE
        C:\PS> Parse-PolFile -Path "C:\Registry.pol"
#>

function Read-GPRegistryPolicyFile
{
    [OutputType([array])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path
    )

    [System.Array] $registryPolicies = @()
    $index = 0
    [System.String] $policyContents = Get-Content $Path -Raw

    $encodingParameter = Get-ByteStreamParameter

    [System.Byte[]] $policyContentInBytes = Get-Content $Path -Raw @encodingParameter

    # 4 bytes are the signature PReg
    $signature = [System.Text.Encoding]::ASCII.GetString($policyContents[0..3])
    $index += 4
    Assert-Condition -Condition ($signature -eq 'PReg') -ErrorMessage ($script:localizedData.InvalidHeader -f $Path)

    # 4 bytes are the version
    $version = [System.BitConverter]::ToInt32($policyContentInBytes, 4)
    $index += 4
    Assert-Condition -Condition ($version -eq 1) -ErrorMessage ($script:localizedData.InvalidVersion -f $Path)

    # Start processing at byte 8
    while ($index -lt $policyContents.Length - 2)
    {
        [System.String] $key = $null
        [System.String] $valueName = $null
        [System.Int32] $valueType = $null
        [System.Int32] $valueLength = $null

        [object]$value = $null

        # Next UNICODE character should be a [
        $leftbracket = [System.BitConverter]::ToChar($policyContentInBytes, $index)
        Assert-Condition -Condition ($leftbracket -eq '[') -ErrorMessage $script:localizedData.MissingOpeningBracket
        $index += 2

        # Next UNICODE string will continue until the ; less the null terminator
        $semicolon = $policyContents.IndexOf(';', $index)
        Assert-Condition -Condition ($semicolon -ge 0) -ErrorMessage $script:localizedData.MissingTrailingSemicolonAfterKey
        $Key = [System.Text.Encoding]::UNICODE.GetString($policyContents[($index)..($semicolon - 3)]) # -3 to exclude the null termination and ';' characters
        $index = $semicolon + 2

        # Next UNICODE string will continue until the ; less the null terminator
        $semicolon = $policyContents.IndexOf(';', $index)
        Assert-Condition -Condition ($semicolon -ge 0) -ErrorMessage $script:localizedData.MissingTrailingSemicolonAfterName
        $valueName = [System.Text.Encoding]::UNICODE.GetString($policyContents[($index)..($semicolon - 3)]) # -3 to exclude the null termination and ';' characters
        $index = $semicolon + 2

        # Next DWORD will continue until the ;
        $semicolon = $index + 4 # DWORD Size
        Assert-Condition -Condition ([System.BitConverter]::ToChar($policyContentInBytes, $semicolon) -eq ';') -ErrorMessage $script:localizedData.MissingTrailingSemicolonAfterType
        $valueType = [System.BitConverter]::ToInt32($policyContentInBytes, $index)
        $index = $semicolon + 2 # Skip ';'

        # Next DWORD will continue until the ;
        $semicolon = $index + 4 # DWORD Size
        Assert-Condition -Condition ([System.BitConverter]::ToChar($policyContentInBytes, $semicolon) -eq ';') -ErrorMessage $script:localizedData.MissingTrailingSemicolonAfterLength
        $valueLength = Convert-StringToInt -ValueString $policyContentInBytes[$index..($index + 3)]
        $index = $semicolon + 2 # Skip ';'

        if ($valueLength -gt 0)
        {
            <#
                String types less the null terminator for REG_SZ and REG_EXPAND_SZ
                REG_SZ: string type (ASCII)
            #>

            if ($valueType -eq [RegType]::REG_SZ)
            {
                # -3 to exclude the null termination and ']' characters
                [System.String] $value = [System.Text.Encoding]::UNICODE.GetString($policyContents[($index)..($index + $valueLength - 3)])
                $index += $valueLength
            }

            # REG_EXPAND_SZ: string, includes %ENVVAR% (expanded by caller) (ASCII)
            if ($valueType -eq [RegType]::REG_EXPAND_SZ)
            {
                # -3 to exclude the null termination and ']' characters
                [System.String] $value = [System.Text.Encoding]::UNICODE.GetString($policyContents[($index)..($index + $valueLength - 3)])
                $index += $valueLength
            }

            <#
                For REG_MULTI_SZ leave the last null terminator
                REG_MULTI_SZ: multiple strings, delimited by \0, terminated by \0\0 (ASCII)
            #>

            if ($valueType -eq [RegType]::REG_MULTI_SZ)
            {
                [System.String] $rawValue = [System.Text.Encoding]::UNICODE.GetString($policyContents[($index)..($index + $valueLength - 3)])
                $value = Format-MultiStringValue -MultiStringValue $rawValue
                $index += $valueLength
            }

            # REG_BINARY: binary values
            if ($valueType -eq [RegType]::REG_BINARY)
            {
                [System.Byte[]] $value = $policyContentInBytes[($index)..($index + $valueLength - 1)]
                $index += $valueLength
            }
        }

        # DWORD: (4 bytes) in little endian format
        if ($valueType -eq [RegType]::REG_DWORD)
        {
            $value = Convert-StringToInt -ValueString $policyContentInBytes[$index..($index + 3)]
            $index += 4
        }

        # QWORD: (8 bytes) in little endian format
        if ($valueType -eq [RegType]::REG_QWORD)
        {
            $value = Convert-StringToInt -ValueString $policyContentInBytes[$index..($index + 7)]
            $index += 8
        }

        # Next UNICODE character should be a ] Skip over null data value if one exists
        $rightbracket = $policyContents.IndexOf(']', $index)
        Assert-Condition -Condition ($rightbracket -ge 0) -ErrorMessage $script:localizedData.MissingClosingBracket
        $index = $rightbracket + 2

        $entry = New-GPRegistryPolicy -Key $Key -ValueName $valueName -ValueType $valueType -ValueLength $valueLength -ValueData $value

        $registryPolicies += $entry
    }

    return $registryPolicies
}

<#
    .SYNOPSIS
        Asserts a condition and throws error if condition fails.
 
    .PARAMETER Condition
        Specifies the condition to test.
 
    .PARAMETER ErrorMessage
        Specifies the error message to throw if the assertion fails.
#>

function Assert-Condition
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Boolean]
        $Condition,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ErrorMessage
    )

    if ($Condition -eq $false)
    {
        throw $ErrorMessage
    }
}

<#
    .SYNOPSIS
        Create a GPRegistryPolicy Object
 
    .PARAMETER Key
        Indicates the path of the registry key for which you want to ensure a specific state. This path must include the hive.
 
    .PARAMETER ValueName
        Indicates the name of the registry value.
 
    .PARAMETER ValueData
        The data for the registry value.
 
    .PARAMETER ValueType
        Indicates the type of the value.
 
    .PARAMETER ValueLength
        Specifies the size of the policy.
#>

function New-GPRegistryPolicy
{
    [CmdletBinding()]
    [OutputType([GPRegistryPolicy])]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Key,

        [Parameter(Position = 1)]
        [System.String]
        $ValueName = $null,

        [Parameter(Position = 2)]
        [RegType]
        $ValueType = [RegType]::REG_NONE,

        [Parameter(Position = 3)]
        [System.String]
        $ValueLength = $null,

        [Parameter(Position = 4)]
        [System.Object[]]
        $ValueData = $null
    )

    $Policy = [GPRegistryPolicy]::new($Key, $ValueName, $ValueType, $ValueLength, $ValueData)

    return $Policy
}

<#
    .SYNOPSIS
        Creates a file and initializes it with Group Policy Registry file format signature.
 
    .DESCRIPTION
        Creates a file and initializes it with Group Policy Registry file format signature.
 
    .PARAMETER Path
        Path to a file (.pol extension).
#>

function New-GPRegistryPolicyFile
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path
    )

    $policyFileSignature = 0x67655250 # PRef
    $policyFileVersion = 0x00000001 #Initially defined as 1, then incremented each time the file format is changed.
    $null = Remove-Item -Path $Path -Force -ErrorAction SilentlyContinue

    Write-Verbose -Message ($script:localizedData.CreateNewPolFile -f $polFilePath)

    $null = New-Item -Path $Path -Force -ErrorAction Stop

    $encodingParameter = Get-ByteStreamParameter
    [System.BitConverter]::GetBytes($policyFileSignature) | Add-Content -Path $Path @encodingParameter
    [System.BitConverter]::GetBytes($policyFileVersion) | Add-Content -Path $Path @encodingParameter
}

<#
    .SYNOPSIS
        Creates a .pol file entry byte array from a GPRegistryPolicy instance.
 
    .DESCRIPTION
        Creates a .pol file entry byte array from a GPRegistryPolicy instance. This entry can be written in a .pol file later.
 
    .PARAMETER RegistryPolicy
        Specifies the registry policy entry.
#>

function New-GPRegistrySettingsEntry
{
    [CmdletBinding()]
    [OutputType([System.Byte[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [GPRegistryPolicy[]]
        $RegistryPolicy
    )

    [byte[]] $entry = @()

    # openning bracket
    $entry += [System.Text.Encoding]::Unicode.GetBytes('[')
    $entry += [System.Text.Encoding]::Unicode.GetBytes($RegistryPolicy.Key + "`0")

    # semicolon as delimiter
    $entry += [System.Text.Encoding]::Unicode.GetBytes(';')
    $entry += [System.Text.Encoding]::Unicode.GetBytes($RegistryPolicy.ValueName + "`0")

    # semicolon as delimiter
    $entry += [System.Text.Encoding]::Unicode.GetBytes(';')
    $entry += [System.BitConverter]::GetBytes([Int32]$RegistryPolicy.ValueType)

    # semicolon as delimiter
    $entry += [System.Text.Encoding]::Unicode.GetBytes(';')

    # get data bytes then compute byte size based on data and type
    switch ($RegistryPolicy.ValueType)
    {
        { @([RegType]::REG_SZ, [RegType]::REG_EXPAND_SZ) -contains $_ }
        {
            $dataBytes = [System.Text.Encoding]::Unicode.GetBytes($RegistryPolicy.ValueData + "`0")
            $dataSize = $dataBytes.Count
        }

        ([RegType]::REG_MULTI_SZ)
        {
            <#
                When REG_MULTI_SZ ValueData contains an array, we need to null terminate each item. Furthermore
                "Data in the Data field to be interpreted as a sequence of characters terminated by two null Unicode
                characters, and within that sequence zero or more null-terminated Unicode strings can exist."
                https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-gpreg/5c092c22-bf6b-4e7f-b180-b20743d368f5
            #>

            $valueDataNullTermJoin = $RegistryPolicy.ValueData -join "`0"
            $dataBytes = [System.Text.Encoding]::Unicode.GetBytes($valueDataNullTermJoin + "`0`0")
            $dataSize = $dataBytes.Count
        }

        ([RegType]::REG_BINARY)
        {
            $dataBytes = [System.Text.Encoding]::Unicode.GetBytes($RegistryPolicy.ValueData)
            $dataSize = $dataBytes.Count
        }

        ([RegType]::REG_DWORD)
        {
            $dataBytes = [System.BitConverter]::GetBytes([Int32] ([string]$RegistryPolicy.ValueData))
            $dataSize = 4
        }

        ([RegType]::REG_QWORD)
        {
            $dataBytes = [System.BitConverter]::GetBytes([Int64]$RegistryPolicy.ValueData)
            $dataSize = 8
        }

        default
        {
            $dataBytes = [System.Text.Encoding]::Unicode.GetBytes("")
            $dataSize = 0
        }
    }

    $entry += [System.BitConverter]::GetBytes($dataSize)

    # semicolon as delimiter
    $entry += [System.Text.Encoding]::Unicode.GetBytes(';')
    $entry += $dataBytes

    # closing bracket
    $entry += [System.Text.Encoding]::Unicode.GetBytes(']')

    return $entry
}

<#
    .SYNOPSIS
        Replaces or adds a registry policy to a .pol file.
 
    .PARAMETER Path
        Path to a file (.pol extension).
 
    .PARAMETER RegistryPolicy
        Specifies the GPRegistryPolicy object to add to the .pol file.
#>

function Set-GPRegistryPolicyFileEntry
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path,

        [Parameter(Mandatory = $true)]
        [GPRegistryPolicy]
        $RegistryPolicy
    )

    $desiredEntries = @()
    $currentPolicies = Read-GPRegistryPolicyFile -Path $Path

    # first check if a entry exists with same key
    $matchingEntries = $currentPolicies | Where-Object -FilterScript { $PSItem.Key -eq $RegistryPolicy.Key -and $PSItem.ValueName -eq $RegistryPolicy.ValueName }

    # if found compare it current policies to validate no duplicate entries
    if ($matchingEntries)
    {
        # compare value data
        foreach ($policy in $matchingEntries)
        {
            if ($policy.ValueData -eq $RegistryPolicy.ValueData)
            {
                Write-Verbose -Message ($script:localizedData.GPRegistryPolicyExists -f $policy.Key, $policy.ValeName, $policy.ValueData)
                return
            }
        }
    }

    # at this point we have validated the desired entry doesn't match any of the current entries so we can add it to existing entries
    $desiredEntries += $currentPolicies | Where-Object -FilterScript { $PSItem.Key -ne $RegistryPolicy.Key -or $PSItem.ValueName -ne $RegistryPolicy.ValueName }
    $desiredEntries += $RegistryPolicy

    # convert entries to byte array

    New-GPRegistryPolicyFile -Path $Path

    $encodingParameter = Get-ByteStreamParameter
    foreach ($desiredEntry in $desiredEntries)
    {
        [System.Byte[]] $entry = New-GPRegistrySettingsEntry -RegistryPolicy $desiredEntry
        $entry | Add-Content -Path $Path -Force @encodingParameter
    }
}

<#
    .SYNOPSIS
        Removes a registry policy from a .pol file.
 
    .PARAMETER Path
        Path to a file (.pol extension).
 
    .PARAMETER RegistryPolicy
        Specifies the GPRegistryPolicy object to remove from the .pol file.
#>

function Remove-GPRegistryPolicyFileEntry
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path,

        [Parameter(Mandatory = $true)]
        [GPRegistryPolicy]
        $RegistryPolicy
    )

    # read pol file
    $currentPolicies = Read-GPRegistryPolicyFile -Path $Path

    # first check if a entry exists with same key
    $matchingEntries = $currentPolicies | Where-Object -FilterScript { $PSItem.Key -eq $RegistryPolicy.Key -and $PSItem.ValueName -eq $RegistryPolicy.ValueName }

    # validate entry exists before removing it.
    if ($null -eq $matchingEntries)
    {
        Write-Verbose -Message ($script:localizedData.NoMatchingPolicies)
        return
    }

    $desiredEntries = $currentPolicies | Where-Object -FilterScript { $PSItem.Key -ne $RegistryPolicy.Key -or $PSItem.ValueName -ne $RegistryPolicy.ValueName }

    # write entries to file
    New-GPRegistryPolicyFile -Path $Path
    $encodingParameter = Get-ByteStreamParameter

    if ($null -ne $desiredEntries)
    {
        foreach ($desiredEntry in $desiredEntries)
        {
            [System.Byte[]] $entry = New-GPRegistrySettingsEntry -RegistryPolicy $desiredEntry
            $entry | Add-Content -Path $Path -Force @encodingParameter
        }
    }
}

<#
    .SYNOPSIS
        Converts a sting to it's unicode characters.
 
    .PARAMETER ValueString
        Specifies the string to convert.
#>

function Convert-StringToInt
{
    [CmdletBinding()]
    [OutputType([System.Int32[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Object]
        $ValueString
    )

    if ($ValueString.Length -le 4)
    {
        [int32] $result = 0
    }
    elseif ($ValueString.Length -le 8)
    {
        [int64] $result = 0
    }
    else
    {
        throw $script:localizedData.InvalidIntegerSize
    }

    for ($i = $ValueString.Length - 1 ; $i -ge 0 ; $i -= 1)
    {
        $result = $result -shl 8
        $result = $result + ([int][char]$ValueString[$i])
    }

    return $result
}

<#
    .SYNOPSIS
        Retrieves the correct parameter to add a byte stream to a file that will be used by the Add-Content cmdlet.
 
    .DESCRIPTION
        Retrieves the correct parameter to add a byte stream to a file that will be used by the Add-Content cmdlet.
        Add-Content in PS Core uses AsByteStream switch
        Add-Content in PS 5.1 uses -Encoding Byte
#>

function Get-ByteStreamParameter
{
    [CmdletBinding()]
    [OutputType([Hashtable])]
    param ()

    if ($PSVersionTable.PSEdition -eq 'Core')
    {
        return @{
            AsByteStream = $true
        }
    }

    return @{
        Encoding = 'Byte'
    }
}

<#
    .SYNOPSIS
        Formats a multistring value.
 
    .DESCRIPTION
        Formats a multistring value by first spliting on \0 and the removing the terminating \0\0.
        This is need to match the desired valueData
#>

function Format-MultiStringValue
{
    [CmdletBinding()]
    [OutputType([System.String[]])]
    param
    (
        [Parameter()]
        [System.Object]
        $MultiStringValue
    )

    $result = @()
    if ($MultiStringValue -match '\0')
    {
        [System.Collections.ArrayList] $array = $MultiStringValue.TrimEnd([char]0) -split '\0'

        # Remove the terminating \0 from all indexes
        foreach ($item in $array)
        {
            $result += $item.TrimEnd([char]0)
        }

        return $result
    }
    else
    {
        # If no terminating 0's are found split on whitespace
        return (-split $MultiStringValue)
    }
}

enum RegType
{
    REG_NONE = 0 # No value type
    REG_SZ = 1 # Unicode null terminated string
    REG_EXPAND_SZ = 2 # Unicode null terminated string (with environmental variable references)
    REG_BINARY = 3 # Free form binary
    REG_DWORD = 4 # 32-bit number
    REG_DWORD_LITTLE_ENDIAN = 4 # 32-bit number (same as REG_DWORD)
    REG_DWORD_BIG_ENDIAN = 5 # 32-bit number
    REG_LINK = 6 # Symbolic link (Unicode)
    REG_MULTI_SZ = 7 # Multiple Unicode strings, delimited by \0, terminated by \0\0
    REG_RESOURCE_LIST = 8 # Resource list in resource map
    REG_FULL_RESOURCE_DESCRIPTOR = 9 # Resource list in hardware description
    REG_RESOURCE_REQUIREMENTS_LIST = 10
    REG_QWORD = 11 # 64-bit number
    REG_QWORD_LITTLE_ENDIAN = 11 # 64-bit number (same as REG_QWORD)
}

<#
    .SYNOPSIS
        Class to create and manage registry policy objects
#>

class GPRegistryPolicy
{
    [System.String]  $Key
    [System.String]  $ValueName
    [RegType] $ValueType
    [System.String]  $ValueLength
    [System.Object]  $ValueData

    GPRegistryPolicy()
    {
        $this.Key = $Null
        $this.ValueName = $null
        $this.ValueType = [RegType]::REG_NONE
        $this.ValueLength = 0
        $this.ValueData = $Null
    }

    GPRegistryPolicy(
        [System.String]  $Key,
        [System.String]  $ValueName,
        [RegType] $ValueType,
        [System.String]  $ValueLength,
        [System.Object]  $ValueData
    )
    {
        $this.Key = $Key
        $this.ValueName = $ValueName
        $this.ValueType = $ValueType
        $this.ValueLength = $ValueLength
        $this.ValueData = $ValueData
    }

    [System.String] GetRegTypeString()
    {
        [System.String] $result = ''

        switch ($this.ValueType)
        {
            ([RegType]::REG_SZ)
            {
                $Result = 'String'
            }
            ([RegType]::REG_EXPAND_SZ)
            {
                $Result = 'ExpandString'
            }
            ([RegType]::REG_BINARY)
            {
                $Result = 'Binary'
            }
            ([RegType]::REG_DWORD)
            {
                $Result = 'DWord'
            }
            ([RegType]::REG_MULTI_SZ)
            {
                $Result = 'MultiString'
            }
            ([RegType]::REG_QWORD)
            {
                $Result = 'QWord'
            }
            default
            {
                $Result = ''
            }
        }

        return $result
    }

    static [RegType] GetRegTypeFromString([System.String] $Type)
    {
        $result = [RegType]::REG_NONE

        switch ($Type)
        {
            'String'
            {
                $result = [RegType]::REG_SZ
            }
            'ExpandString'
            {
                $result = [RegType]::REG_EXPAND_SZ
            }
            'Binary'
            {
                $result = [RegType]::REG_BINARY
            }
            'DWord'
            {
                $result = [RegType]::REG_DWORD
            }
            'MultiString'
            {
                $result = [RegType]::REG_MULTI_SZ
            }
            'QWord'
            {
                $result = [RegType]::REG_QWORD
            }
            default
            {
                $result = [RegType]::REG_NONE
            }
        }

        return $result
    }
}