Public/Compare-PolicyCompliance.ps1

function Compare-PolicyCompliance {
    <#
    .SYNOPSIS
        Compares local policy on a server against a GPO to verify migration completeness.
 
    .DESCRIPTION
        After migrating local policy settings to a GPO with Copy-FirewallToGPO or
        Copy-SecurityPolicyToGPO, use this function to verify the migration was
        complete and accurate.
 
        For firewall rules, the comparison matches rules by DisplayName and compares
        Direction, Action, Protocol, LocalPort, RemotePort, LocalAddress, RemoteAddress,
        Program, and Profile properties.
 
        For security policy, the comparison matches settings by SettingName and compares
        values.
 
        Findings are classified as: MATCH, MISMATCH (with detail on what differs),
        MISSING FROM GPO, or EXTRA IN GPO.
 
        This function is READ-ONLY on both the local server and the GPO.
 
    .PARAMETER ComputerName
        The server to compare local policy from.
 
    .PARAMETER GPOName
        The GPO to compare against.
 
    .PARAMETER CompareType
        What to compare. Valid values: Firewall, SecurityPolicy, Both. Defaults to Both.
 
    .PARAMETER OutputPath
        Optional file path to save an HTML compliance report.
 
    .EXAMPLE
        Compare-PolicyCompliance -ComputerName "SVR-WEB-01" -GPOName "Firewall-WebServers"
 
        Compares both firewall and security policy between the local server and GPO.
 
    .EXAMPLE
        Compare-PolicyCompliance -ComputerName "SVR-WEB-01" -GPOName "Firewall-WebServers" -CompareType Firewall -OutputPath .\compliance.html
 
        Compares only firewall rules and generates an HTML report.
 
    .OUTPUTS
        PSCustomObject with properties: SettingName, LocalValue, GPOValue, Match, CompareType, Finding
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ComputerName,

        [Parameter(Mandatory = $true)]
        [string]$GPOName,

        [Parameter()]
        [ValidateSet('Firewall', 'SecurityPolicy', 'Both')]
        [string]$CompareType = 'Both',

        [Parameter()]
        [string]$OutputPath
    )

    begin {
        $results = [System.Collections.Generic.List[PSObject]]::new()
        Write-Verbose "Compare-PolicyCompliance: READ-ONLY comparison -- no policy will be modified."
    }

    process {
        # ==================================================================
        # Firewall comparison
        # ==================================================================
        if ($CompareType -eq 'Firewall' -or $CompareType -eq 'Both') {
            Write-Verbose "Comparing firewall rules between '$ComputerName' (local) and GPO '$GPOName'..."

            # Read local firewall rules
            $localRules = Get-LocalFirewallPolicy -ComputerName $ComputerName -ErrorAction Stop

            # Read GPO firewall rules
            $gpoRules = @()
            try {
                $domainName = $null
                try {
                    $domainName = (Get-ADDomain -ErrorAction Stop).DNSRoot
                }
                catch {
                    $domainName = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain().Name
                }

                $policyStore = "$domainName\$GPOName"
                $gpoNetRules = Get-NetFirewallRule -PolicyStore $policyStore -ErrorAction Stop

                foreach ($rule in $gpoNetRules) {
                    $addressFilter = $rule | Get-NetFirewallAddressFilter -ErrorAction SilentlyContinue
                    $portFilter    = $rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue
                    $appFilter     = $rule | Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue

                    $gpoRules += [PSCustomObject]@{
                        DisplayName   = $rule.DisplayName
                        Direction     = $rule.Direction.ToString()
                        Action        = $rule.Action.ToString()
                        Protocol      = if ($portFilter) { $portFilter.Protocol } else { 'Any' }
                        LocalPort     = if ($portFilter) { $portFilter.LocalPort } else { 'Any' }
                        RemotePort    = if ($portFilter) { $portFilter.RemotePort } else { 'Any' }
                        LocalAddress  = if ($addressFilter) { $addressFilter.LocalAddress } else { 'Any' }
                        RemoteAddress = if ($addressFilter) { $addressFilter.RemoteAddress } else { 'Any' }
                        Program       = if ($appFilter -and $appFilter.Program -ne 'Any') { $appFilter.Program } else { $null }
                        Profile       = $rule.Profile.ToString()
                        Enabled       = ($rule.Enabled -eq 'True')
                    }
                }
            }
            catch {
                Write-Error "Failed to read GPO firewall rules from '$GPOName': $_"
            }

            Write-Verbose "Local: $($localRules.Count) rule(s), GPO: $($gpoRules.Count) rule(s)"

            # Build lookup of GPO rules by DisplayName
            $gpoRuleLookup = @{}
            foreach ($gpoRule in $gpoRules) {
                $gpoRuleLookup[$gpoRule.DisplayName] = $gpoRule
            }

            $localRuleLookup = @{}
            foreach ($localRule in $localRules) {
                $localRuleLookup[$localRule.DisplayName] = $localRule
            }

            # Compare properties of interest
            $compareProperties = @('Direction', 'Action', 'Protocol', 'LocalPort', 'RemotePort',
                                   'LocalAddress', 'RemoteAddress', 'Program', 'Profile')

            # Check each local rule against GPO
            foreach ($localRule in $localRules) {
                $name = $localRule.DisplayName

                if ($gpoRuleLookup.ContainsKey($name)) {
                    $gpoRule = $gpoRuleLookup[$name]
                    $mismatches = [System.Collections.Generic.List[string]]::new()

                    foreach ($prop in $compareProperties) {
                        $localVal = if ($null -ne $localRule.$prop) { "$($localRule.$prop)" } else { '' }
                        $gpoVal   = if ($null -ne $gpoRule.$prop)   { "$($gpoRule.$prop)" }   else { '' }

                        if ($localVal -ne $gpoVal) {
                            $mismatches.Add("$prop`: Local='$localVal' GPO='$gpoVal'")
                        }
                    }

                    if ($mismatches.Count -eq 0) {
                        $results.Add([PSCustomObject]@{
                            SettingName = $name
                            LocalValue  = "Inbound=$($localRule.Direction) Action=$($localRule.Action) Port=$($localRule.LocalPort)"
                            GPOValue    = "Inbound=$($gpoRule.Direction) Action=$($gpoRule.Action) Port=$($gpoRule.LocalPort)"
                            Match       = $true
                            CompareType = 'Firewall'
                            Finding     = 'MATCH'
                        })
                    }
                    else {
                        $results.Add([PSCustomObject]@{
                            SettingName = $name
                            LocalValue  = "Inbound=$($localRule.Direction) Action=$($localRule.Action) Port=$($localRule.LocalPort)"
                            GPOValue    = "Inbound=$($gpoRule.Direction) Action=$($gpoRule.Action) Port=$($gpoRule.LocalPort)"
                            Match       = $false
                            CompareType = 'Firewall'
                            Finding     = "MISMATCH: $($mismatches -join '; ')"
                        })
                    }
                }
                else {
                    $results.Add([PSCustomObject]@{
                        SettingName = $name
                        LocalValue  = "Inbound=$($localRule.Direction) Action=$($localRule.Action) Port=$($localRule.LocalPort)"
                        GPOValue    = 'N/A'
                        Match       = $false
                        CompareType = 'Firewall'
                        Finding     = 'MISSING FROM GPO'
                    })
                }
            }

            # Check for GPO rules not in local (extra)
            foreach ($gpoRule in $gpoRules) {
                if (-not $localRuleLookup.ContainsKey($gpoRule.DisplayName)) {
                    $results.Add([PSCustomObject]@{
                        SettingName = $gpoRule.DisplayName
                        LocalValue  = 'N/A'
                        GPOValue    = "Inbound=$($gpoRule.Direction) Action=$($gpoRule.Action) Port=$($gpoRule.LocalPort)"
                        Match       = $false
                        CompareType = 'Firewall'
                        Finding     = 'EXTRA IN GPO'
                    })
                }
            }
        }

        # ==================================================================
        # Security Policy comparison
        # ==================================================================
        if ($CompareType -eq 'SecurityPolicy' -or $CompareType -eq 'Both') {
            Write-Verbose "Comparing security policy between '$ComputerName' (local) and GPO '$GPOName'..."

            # Read local security policy
            $localSettings = Get-LocalSecurityPolicy -ComputerName $ComputerName -ErrorAction Stop

            # Read GPO security policy from GptTmpl.inf
            $gpoSettings = @()
            try {
                $domainName = $null
                try {
                    $domainName = (Get-ADDomain -ErrorAction Stop).DNSRoot
                }
                catch {
                    $domainName = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain().Name
                }

                $gpo = Get-GPO -Name $GPOName -ErrorAction Stop
                $gpoId = $gpo.Id.ToString('B').ToUpper()
                $infPath = "\\$domainName\SYSVOL\$domainName\Policies\$gpoId\Machine\Microsoft\Windows NT\SecEdit\GptTmpl.inf"

                if (Test-Path $infPath) {
                    $infContent = Get-Content -Path $infPath -Raw

                    $sectionMap = @{
                        'System Access'    = 'SystemAccess'
                        'Event Audit'      = 'AuditPolicy'
                        'Privilege Rights' = 'UserRights'
                        'Registry Values'  = 'SecurityOptions'
                    }

                    $currentSection = $null
                    $lines = $infContent -split "`r?`n"

                    foreach ($line in $lines) {
                        $trimmed = $line.Trim()
                        if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith(';')) { continue }

                        if ($trimmed -match '^\[(.+)\]$') {
                            $currentSection = $Matches[1]
                            continue
                        }

                        if ($currentSection -and $sectionMap.ContainsKey($currentSection)) {
                            if ($trimmed -match '^(.+?)\s*=\s*(.*)$') {
                                $gpoSettings += [PSCustomObject]@{
                                    Category     = $sectionMap[$currentSection]
                                    SettingName  = $Matches[1].Trim()
                                    SettingValue = $Matches[2].Trim()
                                }
                            }
                        }
                    }
                }
                else {
                    Write-Verbose "No GptTmpl.inf found in GPO '$GPOName'. GPO may not have security settings."
                }
            }
            catch {
                Write-Error "Failed to read GPO security policy from '$GPOName': $_"
            }

            Write-Verbose "Local: $($localSettings.Count) setting(s), GPO: $($gpoSettings.Count) setting(s)"

            # Build lookup of GPO settings by name
            $gpoSettingLookup = @{}
            foreach ($gpoSetting in $gpoSettings) {
                $gpoSettingLookup[$gpoSetting.SettingName] = $gpoSetting
            }

            $localSettingLookup = @{}
            foreach ($localSetting in $localSettings) {
                $localSettingLookup[$localSetting.SettingName] = $localSetting
            }

            # Compare each local setting against GPO
            foreach ($localSetting in $localSettings) {
                $name = $localSetting.SettingName

                if ($gpoSettingLookup.ContainsKey($name)) {
                    $gpoSetting = $gpoSettingLookup[$name]

                    if ($localSetting.SettingValue -eq $gpoSetting.SettingValue) {
                        $results.Add([PSCustomObject]@{
                            SettingName = $name
                            LocalValue  = $localSetting.SettingValue
                            GPOValue    = $gpoSetting.SettingValue
                            Match       = $true
                            CompareType = 'SecurityPolicy'
                            Finding     = 'MATCH'
                        })
                    }
                    else {
                        $results.Add([PSCustomObject]@{
                            SettingName = $name
                            LocalValue  = $localSetting.SettingValue
                            GPOValue    = $gpoSetting.SettingValue
                            Match       = $false
                            CompareType = 'SecurityPolicy'
                            Finding     = "MISMATCH: Local='$($localSetting.SettingValue)' GPO='$($gpoSetting.SettingValue)'"
                        })
                    }
                }
                else {
                    $results.Add([PSCustomObject]@{
                        SettingName = $name
                        LocalValue  = $localSetting.SettingValue
                        GPOValue    = 'N/A'
                        Match       = $false
                        CompareType = 'SecurityPolicy'
                        Finding     = 'MISSING FROM GPO'
                    })
                }
            }

            # Check for GPO settings not in local
            foreach ($gpoSetting in $gpoSettings) {
                if (-not $localSettingLookup.ContainsKey($gpoSetting.SettingName)) {
                    $results.Add([PSCustomObject]@{
                        SettingName = $gpoSetting.SettingName
                        LocalValue  = 'N/A'
                        GPOValue    = $gpoSetting.SettingValue
                        Match       = $false
                        CompareType = 'SecurityPolicy'
                        Finding     = 'EXTRA IN GPO'
                    })
                }
            }
        }

        # Output results
        $results
    }

    end {
        # ------------------------------------------------------------------
        # Generate HTML report if requested
        # ------------------------------------------------------------------
        if ($OutputPath -and $results.Count -gt 0) {
            Write-Verbose "Generating HTML compliance report..."

            try {
                $totalCount    = $results.Count
                $matchCount    = ($results | Where-Object { $_.Finding -eq 'MATCH' }).Count
                $mismatchCount = ($results | Where-Object { $_.Finding -like 'MISMATCH*' }).Count
                $missingCount  = ($results | Where-Object { $_.Finding -eq 'MISSING FROM GPO' }).Count
                $extraCount    = ($results | Where-Object { $_.Finding -eq 'EXTRA IN GPO' }).Count

                $html = New-HtmlDashboard -Title "Policy Compliance: $ComputerName vs GPO '$GPOName'" `
                    -GeneratedDate (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') `
                    -SummaryCards @(
                        @{ Label = 'Total Settings'; Value = $totalCount;    Color = '#56d4dd' }
                        @{ Label = 'Matched';        Value = $matchCount;    Color = '#4caf50' }
                        @{ Label = 'Mismatched';     Value = $mismatchCount; Color = '#f44336' }
                        @{ Label = 'Missing from GPO'; Value = $missingCount; Color = '#ff9800' }
                        @{ Label = 'Extra in GPO';   Value = $extraCount;    Color = '#9c27b0' }
                    ) `
                    -Sections @(
                        @{
                            Title   = 'Compliance Details'
                            Content = $results
                            Type    = 'Table'
                        }
                    ) `
                    -Findings $results

                $outDir = Split-Path -Path $OutputPath -Parent
                if ($outDir -and -not (Test-Path $outDir)) {
                    New-Item -ItemType Directory -Path $outDir -Force | Out-Null
                }

                Set-Content -Path $OutputPath -Value $html -Encoding UTF8
                Write-Verbose "HTML report saved to '$OutputPath'."
                Write-Output "Compliance report saved to: $OutputPath"
            }
            catch {
                Write-Error "Failed to generate HTML report: $_"
            }
        }

        Write-Verbose "Comparison complete: $($results.Count) total finding(s)."
    }
}