Orchestrator/Compare-AssessmentBaseline.ps1
|
function Compare-AssessmentBaseline { <# .SYNOPSIS Compares the current assessment results against a saved baseline. .DESCRIPTION Loads baseline JSON files from the given baseline folder and compares them row-by-row with the security-config CSVs in the current assessment folder. Each check is classified as: Regressed - Status changed from Pass to Fail or Warning Improved - Status changed from Fail/Warning to Pass Modified - CurrentValue changed, Status unchanged New - Check exists in current run but not in baseline Removed - Check exists in baseline but not in current run Unchanged checks are excluded from the returned list but counted. .PARAMETER AssessmentFolder Path to the current assessment output folder (with live CSVs). .PARAMETER BaselineFolder Path to the named baseline folder created by Export-AssessmentBaseline. .PARAMETER RegistryVersion Registry data version of the current run (from controls/registry.json). Compared against the baseline manifest's RegistryVersion to decide whether to do a full or intersect-only comparison. .OUTPUTS [PSCustomObject[]] One entry per changed check with fields: CheckId, Setting, Category, Section, ChangeType, PreviousStatus, CurrentStatus, PreviousValue, CurrentValue .EXAMPLE $drift = Compare-AssessmentBaseline ` -AssessmentFolder $assessmentFolder ` -BaselineFolder '.\M365-Assessment\Baselines\Q1-2026_contoso.com' #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$AssessmentFolder, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$BaselineFolder, [Parameter()] [string]$RegistryVersion = '' ) if (-not (Test-Path -Path $BaselineFolder -PathType Container)) { Write-Error "Baseline folder not found: '$BaselineFolder'" return @() } # Read baseline manifest for version metadata $baselineRegistryVersion = '' $manifestPath = Join-Path -Path $BaselineFolder -ChildPath 'manifest.json' if (Test-Path -Path $manifestPath) { try { $manifestData = Get-Content -Path $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json $baselineRegistryVersion = $manifestData.RegistryVersion } catch { Write-Verbose "Drift: could not read manifest: $_" } } $crossVersionCompare = $RegistryVersion -and $baselineRegistryVersion -and ($RegistryVersion -ne $baselineRegistryVersion) # Build a lookup of all baseline checks: CheckId -> row object $baselineMap = @{} $baselineJsonFiles = Get-ChildItem -Path $BaselineFolder -Filter '*.json' -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'manifest.json' } foreach ($jsonFile in $baselineJsonFiles) { try { $rows = Get-Content -Path $jsonFile.FullName -Raw -ErrorAction Stop | ConvertFrom-Json foreach ($row in $rows) { if ($row.CheckId) { $baselineMap[$row.CheckId] = $row } } } catch { Write-Warning "Drift: could not load baseline file '$($jsonFile.Name)': $_" } } # Build a lookup of all current checks: CheckId -> row object + Section label $currentMap = @{} $csvFiles = Get-ChildItem -Path $AssessmentFolder -Filter '*.csv' -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '_*' } foreach ($csvFile in $csvFiles) { try { $rows = Import-Csv -Path $csvFile.FullName -ErrorAction Stop if (-not $rows) { continue } $firstRow = $rows | Select-Object -First 1 $props = $firstRow.PSObject.Properties.Name if ('CheckId' -notin $props -or 'Status' -notin $props) { continue } # Derive a section label from the filename (e.g. '18b-Defender-Security-Config' -> 'Defender') $sectionLabel = $csvFile.BaseName -replace '^\d+[a-z]*-', '' -replace '-Security-Config$', '' -replace '-', ' ' foreach ($row in $rows) { if ($row.CheckId) { $row | Add-Member -MemberType NoteProperty -Name '_Section' -Value $sectionLabel -Force $currentMap[$row.CheckId] = $row } } } catch { Write-Warning "Drift: could not read '$($csvFile.Name)': $_" } } # In cross-version mode: only compare CheckIDs present in both snapshots. # New/removed CheckIDs are schema changes, not policy drift. $sharedIds = if ($crossVersionCompare) { $currentMap.Keys | Where-Object { $baselineMap.ContainsKey($_) } } else { $currentMap.Keys } $passStatuses = @('Pass') $failStatuses = @('Fail', 'Warning') $driftResults = [System.Collections.Generic.List[PSCustomObject]]::new() # Check shared (or all) current items against baseline foreach ($checkId in $sharedIds) { $current = $currentMap[$checkId] $baseline = $baselineMap[$checkId] if (-not $baseline) { # New check — only reported in same-version mode $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = $current._Section ChangeType = 'New' PreviousStatus = '' CurrentStatus = $current.Status PreviousValue = '' CurrentValue = $current.CurrentValue }) continue } $prevStatus = $baseline.Status $currStatus = $current.Status $prevValue = $baseline.CurrentValue $currValue = $current.CurrentValue if ($prevStatus -eq $currStatus -and $prevValue -eq $currValue) { continue # Unchanged — skip } $changeType = if ($prevStatus -in $failStatuses -and $currStatus -in $passStatuses) { 'Improved' } elseif ($prevStatus -in $passStatuses -and $currStatus -in $failStatuses) { 'Regressed' } else { 'Modified' } $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = $current._Section ChangeType = $changeType PreviousStatus = $prevStatus CurrentStatus = $currStatus PreviousValue = $prevValue CurrentValue = $currValue }) } if ($crossVersionCompare) { # Schema additions: in current but not in baseline registry foreach ($checkId in $currentMap.Keys) { if (-not $baselineMap.ContainsKey($checkId)) { $current = $currentMap[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = $current._Section ChangeType = 'SchemaNew' PreviousStatus = '' CurrentStatus = $current.Status PreviousValue = '' CurrentValue = $current.CurrentValue }) } } # Schema removals: in baseline but not in current registry foreach ($checkId in $baselineMap.Keys) { if (-not $currentMap.ContainsKey($checkId)) { $baseline = $baselineMap[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $baseline.Setting Category = $baseline.Category Section = '' ChangeType = 'SchemaRemoved' PreviousStatus = $baseline.Status CurrentStatus = '' PreviousValue = $baseline.CurrentValue CurrentValue = '' }) } } } else { # Same-version mode: removed checks are policy drift foreach ($checkId in $baselineMap.Keys) { if (-not $currentMap.ContainsKey($checkId)) { $baseline = $baselineMap[$checkId] $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $baseline.Setting Category = $baseline.Category Section = '' ChangeType = 'Removed' PreviousStatus = $baseline.Status CurrentStatus = '' PreviousValue = $baseline.CurrentValue CurrentValue = '' }) } } } return @($driftResults) } |