From bc5daaeadffc3ccfb462d671cf988de332a0ceb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:35:05 +0000 Subject: [PATCH 1/4] Initial plan From 47c0c7ec8212e23550a04f48e2c76ee386064476 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:38:08 +0000 Subject: [PATCH 2/4] Refactor: extract duplicated transformation logic before switch statement Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- Public/Export-AzRetirementReport.ps1 | 55 +++++++++------------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/Public/Export-AzRetirementReport.ps1 b/Public/Export-AzRetirementReport.ps1 index 3d481b3..122baeb 100644 --- a/Public/Export-AzRetirementReport.ps1 +++ b/Public/Export-AzRetirementReport.ps1 @@ -28,37 +28,27 @@ Exports retirement recommendations to CSV, JSON, or HTML 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 { + $rec = $_ | Select-Object -Property * -ExcludeProperty Solution + # 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 + } + $rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force + $rec + } + switch ($Format) { "CSV" { - # Transform data to use Description in Solution column for Az.Advisor mode - # (when Problem and Solution are the same, Description usually has better info) - $transformedRecs = $allRecs | ForEach-Object { - $rec = $_ | Select-Object -Property * -ExcludeProperty Solution - # 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 - } - $rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force - $rec - } $transformedRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8 } "JSON" { - # Transform data to use Description in Solution column for Az.Advisor mode - $transformedRecs = $allRecs | ForEach-Object { - $rec = $_ | Select-Object -Property * -ExcludeProperty Solution - # Use Description if Problem == Solution and Description exists - $solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) { - $_.Description - } else { - $_.Solution - } - $rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force - $rec - } $transformedRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8 } "HTML" { @@ -215,23 +205,14 @@ Exports retirement recommendations to CSV, JSON, or HTML "@ # Add table rows - collect in array for better performance - $tableRows = foreach ($rec in $allRecs) { + $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 - - # Use Description if Problem == Solution (Az.Advisor mode) and Description exists - # This indicates generic duplicated text; use the more informative Description - # For API mode where Problem != Solution, keep original Solution - $solutionText = if ($rec.Problem -eq $rec.Solution -and $rec.Description) { - $rec.Description - } else { - $rec.Solution - } - $encodedSolution = ConvertTo-HtmlEncoded $solutionText + $encodedSolution = ConvertTo-HtmlEncoded $rec.Solution $encodedSubscriptionId = ConvertTo-HtmlEncoded $rec.SubscriptionId # Validate and sanitize CSS class name to prevent CSS injection From 86197f1f4a1c5fe5bd63078e6df013fc8c6cbf60 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Tue, 13 Jan 2026 09:50:44 -0500 Subject: [PATCH 3/4] Update Public/Export-AzRetirementReport.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Public/Export-AzRetirementReport.ps1 | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Public/Export-AzRetirementReport.ps1 b/Public/Export-AzRetirementReport.ps1 index 122baeb..85d6b60 100644 --- a/Public/Export-AzRetirementReport.ps1 +++ b/Public/Export-AzRetirementReport.ps1 @@ -46,7 +46,23 @@ Exports retirement recommendations to CSV, JSON, or HTML switch ($Format) { "CSV" { - $transformedRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8 + # Sanitize potential formula injections for CSV consumers (e.g., Excel) + $safeRecs = $transformedRecs | ForEach-Object { + # Clone the object so JSON/HTML exports remain unaffected + $safe = $_ | Select-Object * + foreach ($prop in $safe.PSObject.Properties) { + $value = $prop.Value + if ($value -is [string] -and $value.Length -gt 0) { + $firstChar = $value[0] + if ($firstChar -in '=','+','-','@') { + # Prefix with a single quote so spreadsheet apps treat it as text + $prop.Value = "'" + $value + } + } + } + $safe + } + $safeRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8 } "JSON" { $transformedRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8 From 7f5bacafd385b89567c819fb82edaf5162e34577 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:54:16 +0000 Subject: [PATCH 4/4] Add functional tests for transformation logic Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- Tests/AzRetirementMonitor.Tests.ps1 | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index 77efa1a..915f931 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -236,6 +236,162 @@ Describe "Export-AzRetirementReport" { } } +Describe "Export-AzRetirementReport Transformation Logic" { + BeforeAll { + # Create a temporary directory for test outputs + $script:TestOutputDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzRetirementMonitorTests_$([guid]::NewGuid())" + New-Item -Path $script:TestOutputDir -ItemType Directory -Force | Out-Null + } + + AfterAll { + # Clean up test output directory + if (Test-Path $script:TestOutputDir) { + Remove-Item -Path $script:TestOutputDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context "When Problem equals Solution (Az.Advisor mode)" { + It "Should replace Solution with Description in CSV when Description exists" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestVM" + ResourceType = "Microsoft.Compute/virtualMachines" + Problem = "Generic retirement notice" + Solution = "Generic retirement notice" + Description = "Detailed upgrade instructions for this VM" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "High" + } + + $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.csv" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false + + $result = Import-Csv -Path $outputPath + $result.Solution | Should -Be "Detailed upgrade instructions for this VM" + } + + It "Should replace Solution with Description in JSON when Description exists" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestStorage" + ResourceType = "Microsoft.Storage/storageAccounts" + Problem = "Service retiring" + Solution = "Service retiring" + Description = "Migrate to new storage account type" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "Medium" + } + + $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.json" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false + + $result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json + $result.Solution | Should -Be "Migrate to new storage account type" + } + + It "Should replace Solution with Description in HTML when Description exists" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestDB" + ResourceType = "Microsoft.Sql/servers/databases" + Problem = "Upgrade required" + Solution = "Upgrade required" + Description = "Move to SQL Database v2" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "Low" + ResourceLink = "https://portal.azure.com/resource" + LearnMoreLink = "https://learn.microsoft.com/azure" + } + + $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.html" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format HTML -Confirm:$false + + $htmlContent = Get-Content -Path $outputPath -Raw + $htmlContent | Should -Match "Move to SQL Database v2" + } + } + + Context "When Problem differs from Solution (API mode)" { + It "Should keep original Solution in CSV when Problem differs" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestApp" + ResourceType = "Microsoft.Web/sites" + Problem = "API version deprecated" + Solution = "Update to API version 2023-01-01" + Description = "Additional context" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "High" + } + + $outputPath = Join-Path $script:TestOutputDir "test-api-mode.csv" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false + + $result = Import-Csv -Path $outputPath + $result.Solution | Should -Be "Update to API version 2023-01-01" + } + + It "Should keep original Solution in JSON when Problem differs" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestFunction" + ResourceType = "Microsoft.Web/sites/functions" + Problem = "Runtime version retiring" + Solution = "Upgrade to .NET 8" + Description = "Migration guide available" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "Medium" + } + + $outputPath = Join-Path $script:TestOutputDir "test-api-mode.json" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false + + $result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json + $result.Solution | Should -Be "Upgrade to .NET 8" + } + } + + Context "Edge cases" { + It "Should keep Solution when Description is null even if Problem equals Solution" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestResource" + ResourceType = "Microsoft.Test/resources" + Problem = "Action required" + Solution = "Action required" + Description = $null + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "Low" + } + + $outputPath = Join-Path $script:TestOutputDir "test-null-description.csv" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false + + $result = Import-Csv -Path $outputPath + $result.Solution | Should -Be "Action required" + } + + It "Should keep Solution when Description is empty string even if Problem equals Solution" { + $testRec = [PSCustomObject]@{ + ResourceName = "TestResource2" + ResourceType = "Microsoft.Test/resources" + Problem = "Update needed" + Solution = "Update needed" + Description = "" + ResourceGroup = "test-rg" + SubscriptionId = "test-sub-id" + Impact = "Low" + } + + $outputPath = Join-Path $script:TestOutputDir "test-empty-description.csv" + $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false + + $result = Import-Csv -Path $outputPath + $result.Solution | Should -Be "Update needed" + } + } +} + Describe "Token Expiration Validation" { BeforeAll { # Helper function to create a test JWT token with specified expiration