Public/Test-WingetCompliance.ps1

function Test-WingetCompliance {
    <#
    .SYNOPSIS
        Test machine package compliance against policy definitions.
 
    .DESCRIPTION
        Enterprise-lite policy enforcement for winget packages. Define required
        packages, banned packages, and version floors in a policy file, then
        test any machine for compliance. Returns structured pass/fail results
        with detailed violation reporting.
 
        Supports auto-remediation: install missing required packages and
        uninstall banned ones with a single switch.
 
    .PARAMETER PolicyPath
        Path to a policy definition file (JSON or YAML).
 
    .PARAMETER RequiredPackages
        Inline array of required package IDs (must be installed).
 
    .PARAMETER BannedPackages
        Inline array of banned package IDs (must NOT be installed).
 
    .PARAMETER VersionFloors
        Hashtable of package ID -> minimum version (e.g., @{'Git.Git'='2.40.0'}).
 
    .PARAMETER Remediate
        Automatically fix violations: install missing required, uninstall banned.
 
    .PARAMETER ExportReport
        Save compliance report to a JSON file.
 
    .PARAMETER Strict
        Fail on ANY violation (default: report all but exit 0 unless -Strict).
 
    .PARAMETER NewPolicy
        Generate a policy template file from current installed packages.
 
    .EXAMPLE
        Test-WingetCompliance -PolicyPath ".\company-policy.json"
        Tests compliance against a shared policy file.
 
    .EXAMPLE
        Test-WingetCompliance -RequiredPackages "Git.Git","Python.Python.3.12" -BannedPackages "TikTok.TikTok"
        Quick inline compliance check.
 
    .EXAMPLE
        Test-WingetCompliance -PolicyPath ".\policy.json" -Remediate
        Test and auto-fix all violations.
 
    .EXAMPLE
        Test-WingetCompliance -NewPolicy -PolicyPath ".\baseline-policy.json"
        Generate a policy template from current machine state.
 
    .NOTES
        Author: Matthew Bubb
        Policy format: JSON with 'required', 'banned', 'version_floors' arrays.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Inline')]
    param(
        [Parameter(ParameterSetName = 'File', Mandatory)]
        [ValidateScript({ Test-Path $_ })]
        [string]$PolicyPath,

        [Parameter(ParameterSetName = 'Inline')]
        [string[]]$RequiredPackages,

        [Parameter(ParameterSetName = 'Inline')]
        [string[]]$BannedPackages,

        [Parameter(ParameterSetName = 'Inline')]
        [hashtable]$VersionFloors,

        [switch]$Remediate,

        [string]$ExportReport,

        [switch]$Strict,

        [Parameter(ParameterSetName = 'Generate', Mandatory)]
        [switch]$NewPolicy
    )

    $configDir = Get-WingetBatchConfigDir

    # --- GENERATE POLICY TEMPLATE ---
    if ($NewPolicy) {
        $packages = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue
        $policy = @{
            name = "Machine Baseline Policy"
            version = "1.0"
            created = (Get-Date -ToString 'o')
            description = "Generated from $($env:COMPUTERNAME) on $(Get-Date -Format 'yyyy-MM-dd')"
            required = @($packages | ForEach-Object { $_.Id } | Sort-Object)
            banned = @()
            version_floors = @{}
        }

        $outPath = $PolicyPath ?? (Join-Path $configDir "compliance_policy.json")
        $policy | ConvertTo-Json -Depth 5 | Set-Content -Path $outPath -Encoding UTF8
        Write-Host ""
        Write-Host " ✓ Policy template generated: $outPath" -ForegroundColor Green
        Write-Host " $($policy.required.Count) packages marked as required." -ForegroundColor DarkGray
        Write-Host " Edit 'banned' and 'version_floors' to customize." -ForegroundColor DarkGray
        Write-Host ""
        return
    }

    # --- LOAD POLICY ---
    $required = @()
    $banned = @()
    $floors = @{}

    if ($PolicyPath) {
        $raw = Get-Content -Path $PolicyPath -Raw
        $policy = $raw | ConvertFrom-Json -AsHashtable
        $required = $policy.required ?? @()
        $banned = $policy.banned ?? @()
        $floors = $policy.version_floors ?? @{}
        $policyName = $policy.name ?? (Split-Path $PolicyPath -Leaf)
    } else {
        $required = $RequiredPackages ?? @()
        $banned = $BannedPackages ?? @()
        $floors = $VersionFloors ?? @{}
        $policyName = "Inline Policy"
    }

    if ($required.Count -eq 0 -and $banned.Count -eq 0 -and $floors.Count -eq 0) {
        Write-Host " No policy rules defined. Nothing to check." -ForegroundColor Yellow
        return
    }

    # --- GET INSTALLED PACKAGES ---
    $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue
    $installedMap = @{}
    foreach ($pkg in $installed) {
        $installedMap[$pkg.Id] = $pkg.InstalledVersion
    }

    # --- EVALUATE COMPLIANCE ---
    $violations = [System.Collections.Generic.List[hashtable]]::new()
    $compliant = [System.Collections.Generic.List[hashtable]]::new()

    # Check required
    foreach ($id in $required) {
        if ($installedMap.ContainsKey($id)) {
            $compliant.Add(@{ Rule = 'Required'; PackageId = $id; Status = 'Pass'; Detail = "Installed ($($installedMap[$id]))" })
        } else {
            $violations.Add(@{ Rule = 'Required'; PackageId = $id; Status = 'Fail'; Detail = 'Not installed'; Remediation = 'Install' })
        }
    }

    # Check banned
    foreach ($id in $banned) {
        if ($installedMap.ContainsKey($id)) {
            $violations.Add(@{ Rule = 'Banned'; PackageId = $id; Status = 'Fail'; Detail = "Installed ($($installedMap[$id])) but prohibited"; Remediation = 'Uninstall' })
        } else {
            $compliant.Add(@{ Rule = 'Banned'; PackageId = $id; Status = 'Pass'; Detail = 'Not installed (correct)' })
        }
    }

    # Check version floors
    foreach ($entry in $floors.GetEnumerator()) {
        $id = $entry.Key
        $minVersion = $entry.Value
        if (-not $installedMap.ContainsKey($id)) {
            $violations.Add(@{ Rule = 'VersionFloor'; PackageId = $id; Status = 'Fail'; Detail = "Not installed (requires >= $minVersion)"; Remediation = 'Install' })
        } else {
            $currentVer = $installedMap[$id]
            try {
                $isCompliant = [version]($currentVer -replace '[^0-9.]', '') -ge [version]($minVersion -replace '[^0-9.]', '')
            } catch {
                $isCompliant = $currentVer -ge $minVersion  # String fallback
            }
            if ($isCompliant) {
                $compliant.Add(@{ Rule = 'VersionFloor'; PackageId = $id; Status = 'Pass'; Detail = "$currentVer >= $minVersion" })
            } else {
                $violations.Add(@{ Rule = 'VersionFloor'; PackageId = $id; Status = 'Fail'; Detail = "$currentVer < $minVersion"; Remediation = 'Update' })
            }
        }
    }

    # --- OUTPUT ---
    $totalChecks = $violations.Count + $compliant.Count
    $isCompliant = $violations.Count -eq 0

    Write-Host ""
    if ($isCompliant) {
        Write-Host " ╔══════════════════════════════════════════════════╗" -ForegroundColor Green
        Write-Host " ║ ✓ COMPLIANT ║" -ForegroundColor Green
        Write-Host " ╚══════════════════════════════════════════════════╝" -ForegroundColor Green
    } else {
        Write-Host " ╔══════════════════════════════════════════════════╗" -ForegroundColor Red
        Write-Host " ║ ✗ NON-COMPLIANT ║" -ForegroundColor Red
        Write-Host " ╚══════════════════════════════════════════════════╝" -ForegroundColor Red
    }
    Write-Host ""
    Write-Host " Policy: " -NoNewline -ForegroundColor DarkGray; Write-Host $policyName -ForegroundColor White
    Write-Host " Checks: " -NoNewline -ForegroundColor DarkGray; Write-Host "$totalChecks ($($compliant.Count) pass, $($violations.Count) fail)" -ForegroundColor White
    Write-Host ""

    # Show violations
    if ($violations.Count -gt 0) {
        Write-Host " Violations:" -ForegroundColor Red
        foreach ($v in $violations) {
            $icon = switch ($v.Rule) { 'Required' { '!' }; 'Banned' { '⊘' }; 'VersionFloor' { '↑' } }
            Write-Host " $icon " -NoNewline -ForegroundColor Red
            Write-Host "$($v.PackageId)" -NoNewline -ForegroundColor White
            Write-Host " [$($v.Rule)] " -NoNewline -ForegroundColor DarkGray
            Write-Host "— $($v.Detail)" -ForegroundColor Red
        }
        Write-Host ""
    }

    # Show passing (verbose)
    if ($VerbosePreference -ne 'SilentlyContinue' -and $compliant.Count -gt 0) {
        Write-Host " Passing:" -ForegroundColor Green
        foreach ($c in $compliant | Select-Object -First 20) {
            Write-Host " ✓ $($c.PackageId) — $($c.Detail)" -ForegroundColor DarkGray
        }
        Write-Host ""
    }

    # --- REMEDIATE ---
    if ($Remediate -and $violations.Count -gt 0) {
        Write-Host " Remediating $($violations.Count) violations..." -ForegroundColor Cyan
        Write-Host ""

        foreach ($v in $violations) {
            switch ($v.Remediation) {
                'Install' {
                    Write-Host " + Installing $($v.PackageId)..." -NoNewline -ForegroundColor Green
                    try {
                        Microsoft.WinGet.Client\Install-WinGetPackage -Id $v.PackageId -Mode Silent | Out-Null
                        Write-Host " ✓" -ForegroundColor Green
                    } catch {
                        Write-Host " ✗ ($($_.Exception.Message))" -ForegroundColor Red
                    }
                }
                'Uninstall' {
                    Write-Host " - Uninstalling $($v.PackageId)..." -NoNewline -ForegroundColor Red
                    try {
                        Microsoft.WinGet.Client\Uninstall-WinGetPackage -Id $v.PackageId -Mode Silent | Out-Null
                        Write-Host " ✓" -ForegroundColor Green
                    } catch {
                        Write-Host " ✗ ($($_.Exception.Message))" -ForegroundColor Red
                    }
                }
                'Update' {
                    Write-Host " ↑ Updating $($v.PackageId)..." -NoNewline -ForegroundColor Yellow
                    try {
                        Microsoft.WinGet.Client\Update-WinGetPackage -Id $v.PackageId -Mode Silent | Out-Null
                        Write-Host " ✓" -ForegroundColor Green
                    } catch {
                        Write-Host " ✗ ($($_.Exception.Message))" -ForegroundColor Red
                    }
                }
            }
        }
        Write-Host ""
        Write-Host " Remediation complete. Re-run to verify compliance." -ForegroundColor Cyan
        Write-Host ""
    }

    # --- EXPORT ---
    if ($ExportReport) {
        $report = @{
            Timestamp = (Get-Date -ToString 'o')
            Hostname = $env:COMPUTERNAME
            Policy = $policyName
            Compliant = $isCompliant
            TotalChecks = $totalChecks
            PassCount = $compliant.Count
            FailCount = $violations.Count
            Violations = $violations
            Passing = $compliant
        }
        $report | ConvertTo-Json -Depth 5 | Set-Content -Path $ExportReport -Encoding UTF8
        Write-Host " Report saved: $ExportReport" -ForegroundColor Green
    }

    # --- RETURN ---
    $result = [PSCustomObject]@{
        Compliant = $isCompliant
        Policy = $policyName
        TotalChecks = $totalChecks
        PassCount = $compliant.Count
        FailCount = $violations.Count
        Violations = $violations | ForEach-Object { [PSCustomObject]$_ }
    }

    if ($Strict -and -not $isCompliant) {
        Write-Error "COMPLIANCE FAILURE: $($violations.Count) violations detected (Strict mode)."
    }

    return $result
}