Public/Export-AzRetirementReport.ps1

function Export-AzRetirementReport {
<#
.SYNOPSIS
Exports retirement recommendations to CSV, JSON, or HTML
.DESCRIPTION
Exports retirement recommendations retrieved from Get-AzRetirementRecommendation to various
formats for reporting and analysis. Works with recommendations from both the default Az.Advisor
method and the API method.
.PARAMETER Recommendations
Recommendation objects from Get-AzRetirementRecommendation (accepts pipeline input)
.PARAMETER OutputPath
File path for the exported report
.PARAMETER Format
Export format: CSV, JSON, or HTML (default: CSV)
.EXAMPLE
Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV
Exports recommendations to CSV format
.EXAMPLE
Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML
Exports recommendations to HTML format
.EXAMPLE
Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath "report.json" -Format JSON
Exports API-sourced recommendations to JSON format
#>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]]$Recommendations,

        [Parameter(Mandatory)]
        [string]$OutputPath,

        [ValidateSet("CSV", "JSON", "HTML")]
        [string]$Format = "CSV"
    )

    begin {
        $allRecs = @()
    }

    process {
        $allRecs += $Recommendations
    }

    end {
        if (-not $PSCmdlet.ShouldProcess($OutputPath, "Export $($allRecs.Count) retirement recommendation(s) as $Format")) {
            return
        }

        # Transform data to use Description in Solution column for Az.Advisor mode
        # (when Problem and Solution are the same, Description usually has better info)
        # This transformation is used for CSV, JSON, and HTML formats
        $transformedRecs = $allRecs | ForEach-Object {
            # Use Description if Problem == Solution and Description exists
            # This indicates Az.Advisor mode where generic text is duplicated
            $solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) {
                $_.Description
            } else {
                $_.Solution
            }
            
            # Create new object with properly converted properties
            # This ensures all properties are strings (not arrays) for proper export
            [PSCustomObject]@{
                SubscriptionId   = $_.SubscriptionId
                ResourceId       = $_.ResourceId
                ResourceName     = $_.ResourceName
                ResourceType     = $_.ResourceType
                ResourceGroup    = $_.ResourceGroup
                Category         = $_.Category
                Impact           = $_.Impact
                Problem          = $_.Problem
                Description      = $_.Description
                LastUpdated      = $_.LastUpdated
                IsRetirement     = $_.IsRetirement
                RecommendationId = $_.RecommendationId
                LearnMoreLink    = $_.LearnMoreLink
                ResourceLink     = $_.ResourceLink
                Solution         = $solutionValue
            }
        }

        switch ($Format) {
            "CSV" {
                # Sanitize potential formula injections for CSV consumers (e.g., Excel)
                $safeRecs = $transformedRecs | ForEach-Object {
                    # Create new object with sanitized values
                    $rec = $_
                    [PSCustomObject]@{
                        SubscriptionId   = if ($rec.SubscriptionId -is [string] -and $rec.SubscriptionId.Length -gt 0 -and $rec.SubscriptionId[0] -in '=','+','-','@') { "'" + $rec.SubscriptionId } else { $rec.SubscriptionId }
                        ResourceId       = if ($rec.ResourceId -is [string] -and $rec.ResourceId.Length -gt 0 -and $rec.ResourceId[0] -in '=','+','-','@') { "'" + $rec.ResourceId } else { $rec.ResourceId }
                        ResourceName     = if ($rec.ResourceName -is [string] -and $rec.ResourceName.Length -gt 0 -and $rec.ResourceName[0] -in '=','+','-','@') { "'" + $rec.ResourceName } else { $rec.ResourceName }
                        ResourceType     = if ($rec.ResourceType -is [string] -and $rec.ResourceType.Length -gt 0 -and $rec.ResourceType[0] -in '=','+','-','@') { "'" + $rec.ResourceType } else { $rec.ResourceType }
                        ResourceGroup    = if ($rec.ResourceGroup -is [string] -and $rec.ResourceGroup.Length -gt 0 -and $rec.ResourceGroup[0] -in '=','+','-','@') { "'" + $rec.ResourceGroup } else { $rec.ResourceGroup }
                        Category         = if ($rec.Category -is [string] -and $rec.Category.Length -gt 0 -and $rec.Category[0] -in '=','+','-','@') { "'" + $rec.Category } else { $rec.Category }
                        Impact           = if ($rec.Impact -is [string] -and $rec.Impact.Length -gt 0 -and $rec.Impact[0] -in '=','+','-','@') { "'" + $rec.Impact } else { $rec.Impact }
                        Problem          = if ($rec.Problem -is [string] -and $rec.Problem.Length -gt 0 -and $rec.Problem[0] -in '=','+','-','@') { "'" + $rec.Problem } else { $rec.Problem }
                        Description      = if ($rec.Description -is [string] -and $rec.Description.Length -gt 0 -and $rec.Description[0] -in '=','+','-','@') { "'" + $rec.Description } else { $rec.Description }
                        LastUpdated      = if ($rec.LastUpdated -is [string] -and $rec.LastUpdated.Length -gt 0 -and $rec.LastUpdated[0] -in '=','+','-','@') { "'" + $rec.LastUpdated } else { $rec.LastUpdated }
                        IsRetirement     = if ($rec.IsRetirement -is [string] -and $rec.IsRetirement.Length -gt 0 -and $rec.IsRetirement[0] -in '=','+','-','@') { "'" + $rec.IsRetirement } else { $rec.IsRetirement }
                        RecommendationId = if ($rec.RecommendationId -is [string] -and $rec.RecommendationId.Length -gt 0 -and $rec.RecommendationId[0] -in '=','+','-','@') { "'" + $rec.RecommendationId } else { $rec.RecommendationId }
                        LearnMoreLink    = if ($rec.LearnMoreLink -is [string] -and $rec.LearnMoreLink.Length -gt 0 -and $rec.LearnMoreLink[0] -in '=','+','-','@') { "'" + $rec.LearnMoreLink } else { $rec.LearnMoreLink }
                        ResourceLink     = if ($rec.ResourceLink -is [string] -and $rec.ResourceLink.Length -gt 0 -and $rec.ResourceLink[0] -in '=','+','-','@') { "'" + $rec.ResourceLink } else { $rec.ResourceLink }
                        Solution         = if ($rec.Solution -is [string] -and $rec.Solution.Length -gt 0 -and $rec.Solution[0] -in '=','+','-','@') { "'" + $rec.Solution } else { $rec.Solution }
                    }
                }
                $safeRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
            }
            "JSON" {
                $transformedRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8
            }
            "HTML" {
                # Helper function to escape HTML to prevent XSS
                function ConvertTo-HtmlEncoded {
                    param([string]$Text)
                    if ([string]::IsNullOrEmpty($Text)) {
                        return $Text
                    }
                    return [System.Net.WebUtility]::HtmlEncode($Text)
                }
                
                # PowerShell 5.1 compatible UTC time (Get-Date -AsUTC is PS 7+ only)
                $generatedTime = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
                $totalCount = $allRecs.Count
                
                # Define CSS for professional styling
                $css = @"
<style>
    body {
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        margin: 20px;
        background-color: #f5f5f5;
        color: #333;
    }
    .container {
        max-width: 1400px;
        margin: 0 auto;
        background-color: white;
        padding: 30px;
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    h1 {
        color: #0078d4;
        border-bottom: 3px solid #0078d4;
        padding-bottom: 10px;
        margin-bottom: 20px;
    }
    .metadata {
        background-color: #f8f9fa;
        padding: 15px;
        border-radius: 5px;
        margin-bottom: 25px;
        border-left: 4px solid #0078d4;
    }
    .metadata p {
        margin: 5px 0;
        font-size: 14px;
    }
    .metadata strong {
        color: #0078d4;
    }
    table {
        width: 100%;
        border-collapse: collapse;
        margin-top: 20px;
        font-size: 14px;
    }
    th {
        background-color: #0078d4;
        color: white;
        padding: 12px 8px;
        text-align: left;
        font-weight: 600;
        position: sticky;
        top: 0;
    }
    td {
        padding: 10px 8px;
        border-bottom: 1px solid #e0e0e0;
        vertical-align: top;
    }
    tr:hover {
        background-color: #f8f9fa;
    }
    .impact-high {
        color: #d13438;
        font-weight: bold;
    }
    .impact-medium {
        color: #ff8c00;
        font-weight: bold;
    }
    .impact-low {
        color: #107c10;
        font-weight: bold;
    }
    .resource-name {
        font-weight: 600;
        color: #0078d4;
    }
    .timestamp {
        color: #666;
        font-size: 12px;
    }
    a {
        color: #0078d4;
        text-decoration: none;
    }
    a:hover {
        text-decoration: underline;
    }
    .recommendation-id {
        font-family: 'Courier New', monospace;
        font-size: 12px;
        color: #666;
    }
    .footer {
        margin-top: 30px;
        padding-top: 20px;
        border-top: 1px solid #e0e0e0;
        text-align: center;
        color: #666;
        font-size: 12px;
    }
</style>
"@


                # Build HTML manually for better control over formatting
                $htmlContent = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Azure Service Retirement Report</title>
    $css
</head>
<body>
    <div class="container">
        <h1>Azure Service Retirement Report</h1>
        <div class="metadata">
            <p><strong>Generated:</strong> $generatedTime</p>
            <p><strong>Total Recommendations:</strong> $totalCount</p>
            <p><strong>Report Type:</strong> Service Retirement and Upgrade Recommendations</p>
            <p><strong>Impact Levels:</strong> Recommendations are categorized as High (critical, address immediately), Medium (important, moderate timeline), or Low (beneficial, lower priority). <a href="https://learn.microsoft.com/azure/advisor/advisor-overview" target="_blank" rel="noopener noreferrer">Learn more about Azure Advisor impact levels</a></p>
        </div>
        <table>
            <thead>
                <tr>
                    <th>Impact</th>
                    <th>Resource Name</th>
                    <th>Resource Type</th>
                    <th>Problem</th>
                    <th>Description</th>
                    <th>Resource Group</th>
                    <th>Subscription ID</th>
                    <th>Resource Link</th>
                    <th>Learn More</th>
                </tr>
            </thead>
            <tbody>
"@


                # Add table rows - collect in array for better performance
                $tableRows = foreach ($rec in $transformedRecs) {
                    # HTML encode all user-provided data to prevent XSS
                    $encodedResourceName = ConvertTo-HtmlEncoded $rec.ResourceName
                    $encodedResourceType = ConvertTo-HtmlEncoded $rec.ResourceType
                    $encodedResourceGroup = ConvertTo-HtmlEncoded $rec.ResourceGroup
                    $encodedImpact = ConvertTo-HtmlEncoded $rec.Impact
                    $encodedProblem = ConvertTo-HtmlEncoded $rec.Problem
                    $encodedSolution = ConvertTo-HtmlEncoded $rec.Solution
                    $encodedSubscriptionId = ConvertTo-HtmlEncoded $rec.SubscriptionId
                    
                    # Validate and sanitize CSS class name to prevent CSS injection
                    $impactClass = switch ($rec.Impact) {
                        "High" { "impact-high" }
                        "Medium" { "impact-medium" }
                        "Low" { "impact-low" }
                        default { "" }
                    }
                    
                    # Build learn more link with proper encoding and validation
                    $encodedLearnMoreLink = if ($rec.LearnMoreLink) {
                        $url = $rec.LearnMoreLink
                        # Validate URL starts with http:// or https:// to prevent javascript: protocol injection
                        if ($url -imatch '^https?://') {
                            $encodedUrl = ConvertTo-HtmlEncoded $url
                            "<a href='$encodedUrl' target='_blank' rel='noopener noreferrer'>Documentation</a>"
                        } else {
                            ConvertTo-HtmlEncoded "Invalid URL"
                        }
                    } else {
                        "N/A"
                    }
                    
                    # Build Resource link with proper encoding and validation
                    $encodedResourceLink = if ($rec.ResourceLink) {
                        $url = $rec.ResourceLink
                        # Validate URL starts with http:// or https:// to prevent javascript: protocol injection
                        if ($url -imatch '^https?://') {
                            $encodedUrl = ConvertTo-HtmlEncoded $url
                            "<a href='$encodedUrl' target='_blank' rel='noopener noreferrer'>View Resource</a>"
                        } else {
                            ConvertTo-HtmlEncoded "Invalid URL"
                        }
                    } else {
                        "N/A"
                    }
                    
                    # Output row HTML
                    @"
                <tr>
                    <td class="$impactClass">$encodedImpact</td>
                    <td class="resource-name">$encodedResourceName</td>
                    <td>$encodedResourceType</td>
                    <td>$encodedProblem</td>
                    <td>$encodedSolution</td>
                    <td>$encodedResourceGroup</td>
                    <td><span class="recommendation-id">$encodedSubscriptionId</span></td>
                    <td>$encodedResourceLink</td>
                    <td>$encodedLearnMoreLink</td>
                </tr>
"@

                }
                
                # Join all rows efficiently
                $htmlContent += ($tableRows -join "")

                # Close HTML
                $htmlContent += @"
            </tbody>
        </table>
        <div class="footer">
            <p>Generated by AzRetirementMonitor | Azure Service Retirement Monitoring Tool<br>
            <a href="https://github.com/cocallaw/AzRetirementMonitor" target="_blank" rel="noopener noreferrer">View on GitHub</a></p>
        </div>
    </div>
</body>
</html>
"@


                $htmlContent | Out-File $OutputPath -Encoding utf8
            }
        }

        Write-Verbose "Report exported to $OutputPath"
    }
}