Public/Compare-CISBenchmarkResults.ps1
|
function Compare-CISBenchmarkResults { <# .SYNOPSIS Compares two CIS benchmark scan results to show compliance trends. .DESCRIPTION Takes a baseline and current JSON report, compares control statuses, and returns a trend analysis showing new failures, resolved issues, regressions, and improvements. .PARAMETER BaselinePath Path to the baseline (older) JSON report file. .PARAMETER CurrentPath Path to the current (newer) JSON report file. .PARAMETER OutputPath Optional path to write an HTML diff report. .EXAMPLE Compare-CISBenchmarkResults -BaselinePath ./reports/baseline.json -CurrentPath ./reports/current.json .EXAMPLE Compare-CISBenchmarkResults -BaselinePath ./baseline.json -CurrentPath ./current.json -OutputPath ./diff.html #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateScript({ if (-not (Test-Path $_ -PathType Leaf)) { throw "Baseline file not found: '$_'. Please provide a valid path to an existing JSON report file." } $true })] [string]$BaselinePath, [Parameter(Mandatory)] [ValidateScript({ if (-not (Test-Path $_ -PathType Leaf)) { throw "Current file not found: '$_'. Please provide a valid path to an existing JSON report file." } $true })] [string]$CurrentPath, [Parameter()] [string]$OutputPath ) # Load reports try { $baseline = Get-Content -Path $BaselinePath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Error "Failed to parse baseline JSON file '$BaselinePath': $($_.Exception.Message)" return } try { $current = Get-Content -Path $CurrentPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Error "Failed to parse current JSON file '$CurrentPath': $($_.Exception.Message)" return } $baseResults = @{} foreach ($r in $baseline.results) { $baseResults[$r.controlId] = $r } $currResults = @{} foreach ($r in $current.results) { $currResults[$r.controlId] = $r } # Categorize changes $newFailures = [System.Collections.Generic.List[PSCustomObject]]::new() $resolved = [System.Collections.Generic.List[PSCustomObject]]::new() $regressions = [System.Collections.Generic.List[PSCustomObject]]::new() $improvements = [System.Collections.Generic.List[PSCustomObject]]::new() $unchanged = [System.Collections.Generic.List[PSCustomObject]]::new() $newControls = [System.Collections.Generic.List[PSCustomObject]]::new() $removedControls = [System.Collections.Generic.List[PSCustomObject]]::new() $statusRank = @{ 'PASS' = 4; 'INFO' = 3; 'WARNING' = 2; 'ERROR' = 1; 'FAIL' = 0 } foreach ($controlId in $currResults.Keys) { $curr = $currResults[$controlId] if (-not $baseResults.ContainsKey($controlId)) { $newControls.Add([PSCustomObject]@{ ControlId = $controlId Title = $curr.title CurrentStatus = $curr.status Category = 'New Control' }) continue } $base = $baseResults[$controlId] $baseRank = if ($null -ne $statusRank[$base.status]) { $statusRank[$base.status] } else { 0 } $currRank = if ($null -ne $statusRank[$curr.status]) { $statusRank[$curr.status] } else { 0 } $change = [PSCustomObject]@{ ControlId = $controlId Title = $curr.title BaselineStatus = $base.status CurrentStatus = $curr.status Section = $curr.section Severity = $curr.severity } if ($base.status -eq $curr.status) { $unchanged.Add($change) } elseif ($base.status -ne 'FAIL' -and $curr.status -eq 'FAIL') { $newFailures.Add($change) } elseif ($base.status -eq 'FAIL' -and $curr.status -eq 'PASS') { $resolved.Add($change) } elseif ($currRank -lt $baseRank) { $regressions.Add($change) } elseif ($currRank -gt $baseRank) { $improvements.Add($change) } else { $unchanged.Add($change) } } # Check for removed controls foreach ($controlId in $baseResults.Keys) { if (-not $currResults.ContainsKey($controlId)) { $base = $baseResults[$controlId] $removedControls.Add([PSCustomObject]@{ ControlId = $controlId Title = $base.title BaselineStatus = $base.status Category = 'Removed' }) } } # Calculate scores $baseScore = $baseline.summary.overallScore $currScore = $current.summary.overallScore $scoreDelta = if ($baseScore -ge 0 -and $currScore -ge 0) { $currScore - $baseScore } else { $null } $result = [PSCustomObject]@{ PSTypeName = 'CISBenchmarkComparison' BaselineScan = $baseline.scanTimestamp CurrentScan = $current.scanTimestamp BaselineScore = $baseScore CurrentScore = $currScore ScoreDelta = $scoreDelta NewFailures = $newFailures.ToArray() Resolved = $resolved.ToArray() Regressions = $regressions.ToArray() Improvements = $improvements.ToArray() Unchanged = $unchanged.ToArray() NewControls = $newControls.ToArray() RemovedControls = $removedControls.ToArray() Summary = [PSCustomObject]@{ NewFailureCount = $newFailures.Count ResolvedCount = $resolved.Count RegressionCount = $regressions.Count ImprovementCount = $improvements.Count UnchangedCount = $unchanged.Count NewControlCount = $newControls.Count RemovedCount = $removedControls.Count } } # Console summary $trend = if ($scoreDelta -gt 0) { "improved +$scoreDelta%" } elseif ($scoreDelta -lt 0) { "declined $([math]::Abs($scoreDelta))%" } elseif ($null -eq $scoreDelta) { 'N/A' } else { 'unchanged' } Write-Host "" Write-Host " CIS Benchmark Trend Analysis" -ForegroundColor Cyan Write-Host " Baseline: $($baseline.scanTimestamp) | Current: $($current.scanTimestamp)" -ForegroundColor DarkGray Write-Host " Score: $baseScore% -> $currScore% ($trend)" -ForegroundColor $(if ($scoreDelta -gt 0) { 'Green' } elseif ($scoreDelta -lt 0) { 'Red' } else { 'Yellow' }) Write-Host " New Failures: $($newFailures.Count) | Resolved: $($resolved.Count) | Regressions: $($regressions.Count) | Improvements: $($improvements.Count)" -ForegroundColor White Write-Host "" # Generate HTML diff report if requested if ($OutputPath) { $htmlLines = [System.Text.StringBuilder]::new() [void]$htmlLines.AppendLine('<!DOCTYPE html><html><head><meta charset="utf-8"><title>CIS Benchmark Trend Report</title>') [void]$htmlLines.AppendLine('<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:1200px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}') [void]$htmlLines.AppendLine('h1{color:#00d4ff}h2{color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}') [void]$htmlLines.AppendLine('table{width:100%;border-collapse:collapse;margin:16px 0}th,td{padding:10px 12px;text-align:left;border-bottom:1px solid #333}') [void]$htmlLines.AppendLine('th{background:#16213e;color:#00d4ff}.pass{color:#00c853}.fail{color:#ff5252}.warn{color:#ffd740}.info{color:#448aff}.error{color:#999}') [void]$htmlLines.AppendLine('.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin:20px 0}') [void]$htmlLines.AppendLine('.summary-card{background:#16213e;border-radius:8px;padding:20px;text-align:center}.summary-card .number{font-size:2em;font-weight:bold}') [void]$htmlLines.AppendLine('.delta-pos{color:#00c853}.delta-neg{color:#ff5252}.delta-zero{color:#ffd740}</style></head><body>') [void]$htmlLines.AppendLine("<h1>CIS Azure Benchmark - Trend Report</h1>") [void]$htmlLines.AppendLine("<p>Baseline: <strong>$([System.Web.HttpUtility]::HtmlEncode($baseline.scanTimestamp))</strong> | Current: <strong>$([System.Web.HttpUtility]::HtmlEncode($current.scanTimestamp))</strong></p>") $deltaClass = if ($scoreDelta -gt 0) { 'delta-pos' } elseif ($scoreDelta -lt 0) { 'delta-neg' } else { 'delta-zero' } $deltaStr = if ($scoreDelta -gt 0) { "+$scoreDelta%" } elseif ($null -eq $scoreDelta) { 'N/A' } else { "$scoreDelta%" } [void]$htmlLines.AppendLine('<div class="summary-grid">') [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number'>$([System.Web.HttpUtility]::HtmlEncode($baseScore))%</div><div>Baseline Score</div></div>") [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number'>$([System.Web.HttpUtility]::HtmlEncode($currScore))%</div><div>Current Score</div></div>") [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number $deltaClass'>$([System.Web.HttpUtility]::HtmlEncode($deltaStr))</div><div>Change</div></div>") [void]$htmlLines.AppendLine('</div>') [void]$htmlLines.AppendLine('<div class="summary-grid">') [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number fail'>$($newFailures.Count)</div><div>New Failures</div></div>") [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number pass'>$($resolved.Count)</div><div>Resolved</div></div>") [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number warn'>$($regressions.Count)</div><div>Regressions</div></div>") [void]$htmlLines.AppendLine("<div class='summary-card'><div class='number info'>$($improvements.Count)</div><div>Improvements</div></div>") [void]$htmlLines.AppendLine('</div>') # Table helper $tableBuilder = { param($title, $items, $showBaseline) if ($items.Count -eq 0) { return } [void]$htmlLines.AppendLine("<h2>$title ($($items.Count))</h2><table><tr><th>Control</th><th>Title</th>") if ($showBaseline) { [void]$htmlLines.AppendLine('<th>Baseline</th><th>Current</th>') } else { [void]$htmlLines.AppendLine('<th>Status</th>') } [void]$htmlLines.AppendLine('</tr>') foreach ($item in $items) { $currClass = switch ($item.CurrentStatus) { 'PASS' { 'pass' } 'FAIL' { 'fail' } 'WARNING' { 'warn' } 'INFO' { 'info' } default { 'error' } } [void]$htmlLines.AppendLine("<tr><td>$([System.Web.HttpUtility]::HtmlEncode($item.ControlId))</td><td>$([System.Web.HttpUtility]::HtmlEncode($item.Title))</td>") if ($showBaseline) { $baseClass = switch ($item.BaselineStatus) { 'PASS' { 'pass' } 'FAIL' { 'fail' } 'WARNING' { 'warn' } 'INFO' { 'info' } default { 'error' } } [void]$htmlLines.AppendLine("<td class='$baseClass'>$([System.Web.HttpUtility]::HtmlEncode($item.BaselineStatus))</td><td class='$currClass'>$([System.Web.HttpUtility]::HtmlEncode($item.CurrentStatus))</td>") } else { [void]$htmlLines.AppendLine("<td class='$currClass'>$([System.Web.HttpUtility]::HtmlEncode($item.CurrentStatus))</td>") } [void]$htmlLines.AppendLine('</tr>') } [void]$htmlLines.AppendLine('</table>') } & $tableBuilder 'New Failures' $newFailures $true & $tableBuilder 'Resolved' $resolved $true & $tableBuilder 'Regressions' $regressions $true & $tableBuilder 'Improvements' $improvements $true [void]$htmlLines.AppendLine('<p style="color:#666;margin-top:40px;font-size:0.85em">Generated by CIS Azure Benchmark Module</p>') [void]$htmlLines.AppendLine('</body></html>') $htmlLines.ToString() | Out-File -FilePath $OutputPath -Encoding UTF8 -Force Write-Host " Trend report written to: $OutputPath" -ForegroundColor Green } return $result } |