Orchestrator/Compare-M365Baseline.ps1
|
function Compare-M365Baseline { <# .SYNOPSIS Compares two saved baseline snapshots to produce a drift report without re-running an assessment. .DESCRIPTION Reads the security-config JSON files from two saved baseline folders and produces a drift HTML report showing Improved, Regressed, Modified, New, and Removed checks between the two points in time. When -BaselineB is omitted the most recent saved baseline for the tenant is used as the "current" reference, allowing you to compare any historical baseline against the closest subsequent one. .PARAMETER BaselineA Label of the earlier (reference) baseline to compare from. .PARAMETER BaselineB Label of the later baseline to compare to. Defaults to the most recent saved baseline for the tenant. .PARAMETER TenantId Tenant identifier used to locate the baseline folders under <OutputFolder>/Baselines/<Label>_<TenantId>/. .PARAMETER OutputFolder Root output folder (parent of Baselines/). Defaults to the current directory. .PARAMETER OutputPath Path for the generated HTML report. Defaults to _Drift-<BaselineA>-vs-<BaselineB>.html in the OutputFolder. .EXAMPLE PS> Compare-M365Baseline -BaselineA 'Q1-2026' -BaselineB 'Q2-2026' -TenantId 'contoso.com' Generates a drift report between two quarterly baselines. .EXAMPLE PS> Compare-M365Baseline -BaselineA 'Q1-2026' -TenantId 'contoso.com' Compares Q1-2026 against the most recent other saved baseline for the tenant. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$BaselineA, [Parameter()] [string]$BaselineB, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$TenantId, [Parameter()] [string]$OutputFolder = '.', [Parameter()] [string]$OutputPath ) $safeTenant = $TenantId -replace '[^\w\.\-]', '_' $safeA = $BaselineA -replace '[^\w\-]', '_' $baselinesRoot = Join-Path -Path $OutputFolder -ChildPath 'Baselines' $folderA = Join-Path -Path $baselinesRoot -ChildPath "${safeA}_${safeTenant}" if (-not (Test-Path -Path $folderA -PathType Container)) { Write-Error "Baseline '$BaselineA' not found at '$folderA'" return } # Resolve BaselineB: explicit label, or most recent other baseline for tenant if ($BaselineB) { $safeB = $BaselineB -replace '[^\w\-]', '_' $folderB = Join-Path -Path $baselinesRoot -ChildPath "${safeB}_${safeTenant}" if (-not (Test-Path -Path $folderB -PathType Container)) { Write-Error "Baseline '$BaselineB' not found at '$folderB'" return } $labelB = $BaselineB } else { $otherFolders = Get-ChildItem -Path $baselinesRoot -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "*_${safeTenant}" -and $_.Name -ne "${safeA}_${safeTenant}" } | Sort-Object LastWriteTime -Descending if (-not $otherFolders) { Write-Error "No other baselines found for tenant '$TenantId' to compare against." return } $folderB = $otherFolders[0].FullName $labelB = $otherFolders[0].Name -replace "_${safeTenant}$", '' } # Read manifest version info for cross-version detection $regVersionA = '' $regVersionB = '' $timestampA = '' $timestampB = '' foreach ($info in @( @{ Folder = $folderA; VersionRef = [ref]$regVersionA; TsRef = [ref]$timestampA } @{ Folder = $folderB; VersionRef = [ref]$regVersionB; TsRef = [ref]$timestampB } )) { $mPath = Join-Path -Path $info.Folder -ChildPath 'manifest.json' if (Test-Path -Path $mPath) { try { $m = Get-Content -Path $mPath -Raw | ConvertFrom-Json $info.VersionRef.Value = $m.RegistryVersion $info.TsRef.Value = $m.SavedAt } catch { Write-Verbose "Compare-M365Baseline: could not read manifest at '$mPath': $_" } } } $crossVersion = $regVersionA -and $regVersionB -and ($regVersionA -ne $regVersionB) # Build check maps from baseline JSON files $mapA = @{} $mapB = @{} foreach ($pair in @( @{ Folder = $folderA; Map = [ref]$mapA } @{ Folder = $folderB; Map = [ref]$mapB } )) { Get-ChildItem -Path $pair.Folder -Filter '*.json' -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'manifest.json' } | ForEach-Object { try { $rows = Get-Content -Path $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json foreach ($row in $rows) { if ($row.CheckId) { $pair.Map.Value[$row.CheckId] = $row } } } catch { Write-Warning "Could not load '$($_.Name)': $_" } } } $sharedIds = if ($crossVersion) { $mapB.Keys | Where-Object { $mapA.ContainsKey($_) } } else { $mapB.Keys } $passStatuses = @('Pass') $failStatuses = @('Fail', 'Warning') $driftResults = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($checkId in $sharedIds) { $current = $mapB[$checkId] $baseline = $mapA[$checkId] if (-not $baseline) { $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = '' ChangeType = 'New' PreviousStatus = '' CurrentStatus = $current.Status PreviousValue = '' CurrentValue = $current.CurrentValue }) continue } if ($baseline.Status -eq $current.Status -and $baseline.CurrentValue -eq $current.CurrentValue) { continue } $changeType = if ($baseline.Status -in $failStatuses -and $current.Status -in $passStatuses) { 'Improved' } elseif ($baseline.Status -in $passStatuses -and $current.Status -in $failStatuses) { 'Regressed' } else { 'Modified' } $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = '' ChangeType = $changeType PreviousStatus = $baseline.Status CurrentStatus = $current.Status PreviousValue = $baseline.CurrentValue CurrentValue = $current.CurrentValue }) } if ($crossVersion) { foreach ($checkId in $mapB.Keys) { if (-not $mapA.ContainsKey($checkId)) { $row = $mapB[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $row.Setting Category = $row.Category Section = '' ChangeType = 'SchemaNew' PreviousStatus = '' CurrentStatus = $row.Status PreviousValue = '' CurrentValue = $row.CurrentValue }) } } foreach ($checkId in $mapA.Keys) { if (-not $mapB.ContainsKey($checkId)) { $row = $mapA[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $row.Setting Category = $row.Category Section = '' ChangeType = 'SchemaRemoved' PreviousStatus = $row.Status CurrentStatus = '' PreviousValue = $row.CurrentValue CurrentValue = '' }) } } } else { foreach ($checkId in $mapA.Keys) { if (-not $mapB.ContainsKey($checkId)) { $row = $mapA[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $row.Setting Category = $row.Category Section = '' ChangeType = 'Removed' PreviousStatus = $row.Status CurrentStatus = '' PreviousValue = $row.CurrentValue CurrentValue = '' }) } } } # Build and write drift HTML $commonDir = Join-Path -Path $PSScriptRoot -ChildPath '..\Common' if (-not (Get-Command -Name Build-DriftHtml -ErrorAction SilentlyContinue)) { . (Join-Path -Path $commonDir -ChildPath 'Build-DriftHtml.ps1') } $driftHtmlFragment = Build-DriftHtml ` -DriftReport @($driftResults) ` -BaselineLabel $BaselineA ` -BaselineTimestamp $timestampA if (-not $OutputPath) { $safeLabelB = $labelB -replace '[^\w\-]', '_' $OutputPath = Join-Path -Path $OutputFolder -ChildPath "_Drift-${safeA}-vs-${safeLabelB}.html" } # Wrap in a minimal standalone HTML page $html = @" <!DOCTYPE html> <html lang="en"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <title>Drift: $BaselineA vs $labelB</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #f8fafc; color: #1e293b; } h1 { font-size: 1.4em; margin-bottom: 4px; } .meta { font-size: 0.85em; color: #64748b; margin-bottom: 20px; } </style> </head> <body> <h1>Policy Drift: $BaselineA → $labelB</h1> <div class="meta">Tenant: $TenantId | $($driftResults.Count) changes detected</div> $driftHtmlFragment </body> </html> "@ Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Output "Drift report generated: $OutputPath ($($driftResults.Count) changes)" } |