Public/Invoke-AzPolicyWatch.ps1

function Invoke-AzPolicyWatch {
    <#
    .SYNOPSIS
        Run a complete Azure Policy compliance analysis with a single command.
 
    .DESCRIPTION
        This function orchestrates the entire AzurePolicyWatch workflow:
        1. Retrieve policy baselines (ALZ and/or MCSB)
        2. Scan Azure policy assignments (Management Group or Subscription)
        3. Compare assignments against baseline
        4. Export reports (CSV and optionally HTML)
 
        This is a convenience wrapper around the 4 core functions:
        - Get-AzPolicyBaseline
        - Get-AzPolicyAssignmentScan
        - Compare-AzPolicyCompliance
        - Export-PolicyReport
 
    .PARAMETER ManagementGroupId
        Management Group ID to scan for policy assignments.
        Mutually exclusive with -SubscriptionId.
 
    .PARAMETER SubscriptionId
        Subscription ID to scan for policy assignments.
        Mutually exclusive with -ManagementGroupId.
 
    .PARAMETER OutputFolder
        Destination folder for generated reports.
        Default: C:\Temp\Policy-Compare
 
    .PARAMETER CsvPath
        Path to local AzAdvertizer CSV file (alternative to -CsvUrl).
     
    .PARAMETER ExcludeBaselineTypes
        Exclude specific baseline types from comparison.
        Valid values: "ALZ", "MCSB"
        Example: @("ALZ") to compare only against MCSB
        Example: @("MCSB") to compare only against ALZ
        Example: @("ALZ", "MCSB") to compare only custom policies
 
    .PARAMETER MatchByNameOnly
        Match policies by normalized name only (ignore IDs).
        Useful when baseline uses built-in IDs but assignments use custom definitions.
        Default: $true
 
    .PARAMETER HtmlReport
        Generate HTML report in addition to CSV files.
 
    .PARAMETER ProjectName
        Project name for HTML report header.
        Default: AzurePolicyWatch
 
    .PARAMETER ProjectVersion
        Version string for HTML report.
        Default: 1.0.0
 
    .PARAMETER Quiet
        Suppress progress output (only show errors and final summary).
 
    .PARAMETER ExcludeExtraPolicies
        Exclude extra policies (not in baseline) from the comparison report.
 
    .EXAMPLE
        # Scan Management Group with HTML report
        Invoke-AzPolicyWatch -ManagementGroupId "MyRootMG" -HtmlReport
 
    .EXAMPLE
        # Scan specific subscription (ALZ only, no MCSB)
        Invoke-AzPolicyWatch -SubscriptionId "12345678-abcd-1234-abcd-123456789012" `
                             -IncludeMcsb:"]false
 
    .EXAMPLE
        # Custom output and filtered initiatives
        Invoke-AzPolicyWatch -ManagementGroupId "MyMG" `
                             -OutputFolder "C:\Reports\PolicyAudit" `
                             -SelectedAlzInitiatives @("alzroot", "alz-Identity") `
                             -HtmlReport
 
    .EXAMPLE
        # Quiet mode with custom CSV
        Invoke-AzPolicyWatch -SubscriptionId "xxx" `
                             -CsvPath "C:\Data\policies.csv" `
                             -Quiet
 
    .NOTES
        Requires:
        - PowerShell 7.0
        - Az.Accounts module (Connect-AzAccount)
        - Az.Resources module
         
        Before running:
        1. Connect-AzAccount
        2. Set-AzContext (if multiple subscriptions)
 
    .LINK
        https://github.com/technicalandcloud/AzurePolicyWatch
    #>


    [CmdletBinding(DefaultParameterSetName = 'ManagementGroup')]
    param(
        [Parameter(ParameterSetName = 'ManagementGroup', Mandatory = $true)]
        [string]$ManagementGroupId,
        
        [Parameter(ParameterSetName = 'Subscription', Mandatory = $true)]
        [string]$SubscriptionId,
        
        [Parameter()]
        [ValidateSet("ALZ", "MCSB")]
        [string[]]$ExcludeBaselineTypes = @(),
        
        [string]$OutputFolder = "C:\Temp\Policy-Compare",
        
        [string]$CsvPath = "",
        
        [switch]$HtmlReport,
        [string]$ProjectName = "AzurePolicyWatch",
        [string]$ProjectVersion = "1.0.1",
        [switch]$Quiet,
        
        [Parameter()]
        [switch]$ExcludeExtraPolicies
    )
    $IncludeAlz = $true
    $IncludeMcsb = $true
    $CsvUrl = ""
    $CacheMaxAgeHours = 24
    $SelectedAlzInitiatives = @()
    $ExcludeAssignments = @("Microsoft cloud security benchmark")
    $MatchByNameOnly = $true


    
    function Format-CenteredLine {
        <#
        .SYNOPSIS
            Centers text within a specified width for display in a banner.
        #>

        param(
            [string]$Text,
            [int]$Width = 60
        )
        
        $padding = $Width - $Text.Length
        $leftPad = [math]::Floor($padding / 2)
        $rightPad = [math]::Ceiling($padding / 2)
        
        return (" " * $leftPad) + $Text + (" " * $rightPad)
    }


    
    try {
        $context = Get-AzContext
        if (-not $context) {
            throw "No Azure context found. Please run Connect-AzAccount first."
        }
        
        if (-not $Quiet) {
            Write-Host "✅ Azure context: $($context.Account.Id)" -ForegroundColor Green
        }
        
    } catch {
        Write-Error $_.Exception.Message
        Write-Host ""
        Write-Host "❌ Azure authentication required" -ForegroundColor Red
        Write-Host " Please run: Connect-AzAccount" -ForegroundColor Yellow
        return
    }


    
    $reportsFolder = Join-Path $OutputFolder "reports"
    $baselineFolder = Join-Path $OutputFolder "baseline"
    $initiativesFolder = Join-Path $OutputFolder "initiatives"

    @($reportsFolder, $baselineFolder, $initiativesFolder) | ForEach-Object {
        if (-not (Test-Path $_)) {
            New-Item -Path $_ -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
        }
    }


    
    if (-not $Quiet) {
        Write-Host ""
        Write-Host "╔════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
        
        $titleLine = Format-CenteredLine "🚀 $ProjectName v$ProjectVersion"
        Write-Host "║$titleLine║" -ForegroundColor Cyan
        
        $subtitleLine = Format-CenteredLine "Azure Policy Compliance Analysis"
        Write-Host "║$subtitleLine║" -ForegroundColor Cyan
        
        Write-Host "╚════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
        Write-Host ""
        
        $scopeDescription = if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
            "Management Group: $ManagementGroupId"
        } else {
            "Subscription: $SubscriptionId"
        }
        
        Write-Host "📍 Scope: $scopeDescription" -ForegroundColor White
        Write-Host "📁 Output: $OutputFolder" -ForegroundColor White
        Write-Host ""
    }


    
    if (-not $Quiet) {
        Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkGray
        Write-Host "📊 Step 1/4: Retrieving policy baseline..." -ForegroundColor Cyan
    }

    try {
        $baselineParams = @{ 
            IncludeAlz = $IncludeAlz
            IncludeMcsb = $IncludeMcsb
            CacheMaxAgeHours = $CacheMaxAgeHours
        }
        
        if ($CsvPath) { $baselineParams.CsvPath = $CsvPath }
        elseif ($CsvUrl) { $baselineParams.CsvUrl = $CsvUrl }
        
        if ($SelectedAlzInitiatives.Count -gt 0) {
            $baselineParams.SelectedAlzInitiatives = $SelectedAlzInitiatives
        }
        
        $baseline = Get-AzPolicyBaseline @baselineParams
        

        
        if ($ExcludeBaselineTypes.Count -gt 0) {
            Write-Verbose "Filtering baseline to exclude: $($ExcludeBaselineTypes -join ', ')"
            
            $originalCount = $baseline.Policies.Count
            
            $filteredPolicies = $baseline.Policies | Where-Object {
                $policy = $_
                $shouldKeep = $true
                
                foreach ($excludeType in $ExcludeBaselineTypes) {
                    if ($policy.BaselineSources -like "*$excludeType*") {
                        $shouldKeep = $false
                        break
                    }
                }
                
                $shouldKeep
            }
            

            $baseline.Policies = $filteredPolicies
            

            $baseline.Index = New-BaselineIndex -Baseline $filteredPolicies
            
            $removedCount = $originalCount - $filteredPolicies.Count
            
            if ($removedCount -gt 0 -and -not $Quiet) {
                Write-Host " ℹ️ Filtered baseline: $originalCount → $($filteredPolicies.Count) policies (excluded $($ExcludeBaselineTypes -join ', '))" -ForegroundColor Cyan
            }
            
            Write-Verbose " Removed $removedCount baseline policies from comparison"
        }
        

        
        if (-not $Quiet) {
            $alzCount = ($baseline.Policies | Where-Object { $_.BaselineSources -like '*ALZ*' }).Count
            $mcsbCount = ($baseline.Policies | Where-Object { $_.BaselineSources -like '*MCSB*' }).Count
            
            Write-Host " ✅ Baseline loaded: $($baseline.Policies.Count) policies" -ForegroundColor Green
            if ($IncludeAlz -and $alzCount -gt 0) { 
                Write-Host " ALZ: $alzCount policies" -ForegroundColor White 
            }
            if ($IncludeMcsb -and $mcsbCount -gt 0) { 
                Write-Host " MCSB: $mcsbCount policies" -ForegroundColor White 
            }
        }
        
    } catch {
        Write-Error "Failed to load baseline: $($_.Exception.Message)"
        return
    }


    
    if (-not $Quiet) {
        Write-Host ""
        Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkGray
        Write-Host "🔍 Step 2/4: Scanning policy assignments..." -ForegroundColor Cyan
    }

    try {
        $scanParams = @{ 
            ExcludeAssignments = $ExcludeAssignments
        }
        
        if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
            $scanParams.ManagementGroupId = $ManagementGroupId
        } else {
            $scanParams.SubscriptionId = $SubscriptionId
        }
        
        $assignments = Get-AzPolicyAssignmentScan @scanParams
        
        if (-not $Quiet) {
            $totalPolicies = ($assignments | ForEach-Object { $_.ExpandedPolicies.Count } | Measure-Object -Sum).Sum
            Write-Host " ✅ Scan complete: $($assignments.Count) assignments ($totalPolicies expanded policies)" -ForegroundColor Green
        }
        
    } catch {
        Write-Error "Failed to scan assignments: $($_.Exception.Message)"
        return
    }


    
    if (-not $Quiet) {
        Write-Host ""
        Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkGray
        Write-Host "⚖️ Step 3/4: Comparing against baseline..." -ForegroundColor Cyan
    }

    try {
        $comparisonParams = @{ 
            Baseline = $baseline
            Assignments = $assignments
            MatchByNameOnly = $MatchByNameOnly
            InitiativesFolder = $initiativesFolder
            ExcludeExtraPolicies = $ExcludeExtraPolicies.IsPresent
        }
        
        $comparison = Compare-AzPolicyCompliance @comparisonParams
        
        if (-not $Quiet) {

            $matchedCount = if ($comparison.Details.InCommon) { $comparison.Details.InCommon.Count } else { 0 }
            $missingCount = if ($comparison.Details.Missing) { $comparison.Details.Missing.Count } else { 0 }
            $extraCount = if ($ExcludeExtraPolicies) { 
                0 
            } elseif ($comparison.Details.Extra) { 
                $comparison.Details.Extra.Count 
            } else { 
                0 
            }
            $mismatchCount = if ($comparison.Details.VersionMismatches) { 
                $comparison.Details.VersionMismatches.Count 
            } else { 
                0 
            }
            
            Write-Host " ✅ Analysis complete:" -ForegroundColor Green
            Write-Host " Matched: $matchedCount" -ForegroundColor White
            Write-Host " Missing: $missingCount" -ForegroundColor White
            if ($ExcludeExtraPolicies) {
                Write-Host " Extra: (filtered out)" -ForegroundColor DarkGray
            } else {
                Write-Host " Extra: $extraCount" -ForegroundColor White
            }
            Write-Host " Version Mismatch: $mismatchCount" -ForegroundColor White
        }
        
    } catch {
        Write-Error "Failed to compare policies: $($_.Exception.Message)"
        return
    }

    
    if (-not $Quiet) {
        Write-Host ""
        Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkGray
        Write-Host "📄 Step 4/4: Generating reports..." -ForegroundColor Cyan
    }

    try {
        $exportFormat = if ($HtmlReport) { 'All' } else { 'CSV' }
        

        $scopeDescription = if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
            "Management Group: $ManagementGroupId"
        } else {
            "Subscription: $SubscriptionId"
        }
        
        $exportParams = @{ 
            ComparisonResult = $comparison
            OutputFolder = $OutputFolder
            Baseline = $baseline
            Assignments = $assignments
            Format = $exportFormat
            MatchByNameOnly = $MatchByNameOnly
            HtmlOptions = @{ 
                ProjectName = $ProjectName
                ProjectVersion = $ProjectVersion
                Scope = $scopeDescription
                ExcludeExtraPolicies = $ExcludeExtraPolicies.IsPresent
            }
        }
        
        Write-Verbose "Calling Export-PolicyReport..."
        $generatedFiles = @(Export-PolicyReport @exportParams)
        
        
        if (-not $Quiet) {
            Write-Host ""
            Write-Host " ✅ Reports generated successfully" -ForegroundColor Green
            Write-Host ""
            
            if ($generatedFiles -and $generatedFiles.Count -gt 0) {
                Write-Host " 📁 Generated files:" -ForegroundColor Cyan
                Write-Host ""
                
                foreach ($file in $generatedFiles) {
                    if ($file -and (Test-Path $file)) {
                        $fileInfo = Get-Item $file
                        $fileName = $fileInfo.Name
                        $fileSizeKB = [math]::Round($fileInfo.Length / 1KB, 1)
                        
                        # Icon based on file type
                        $icon = switch -Wildcard ($fileName) {
                            "*.html" { "🌐" }
                            "*Baseline*" { "📊" }
                            "*Summary*" { "📋" }
                            "*Details*" { "📝" }
                            "*Hierarchical*" { "🏗️" }
                            default { "📄" }
                        }
                        
                        Write-Host " $icon $fileName " -NoNewline -ForegroundColor White
                        Write-Host "($fileSizeKB KB)" -ForegroundColor Gray
                    }
                }
            }
            
            Write-Host ""
            Write-Host " 📂 Location: $OutputFolder" -ForegroundColor Cyan
            Write-Host ""
            Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor DarkGray
            Write-Host "✨ Analysis completed successfully!" -ForegroundColor Green
            Write-Host ""
        }
        
    } catch {
        Write-Error "Failed to export reports: $($_.Exception.Message)"
        return
    }
}