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. .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 ) if (-not (Test-Path -Path $BaselineFolder -PathType Container)) { Write-Error "Baseline folder not found: '$BaselineFolder'" return @() } # Build a lookup of all baseline checks: CheckId -> row object $baselineMap = @{} $baselineJsonFiles = Get-ChildItem -Path $BaselineFolder -Filter '*.json' -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne '_baseline-metadata.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)': $_" } } $passStatuses = @('Pass') $failStatuses = @('Fail', 'Warning') $driftResults = [System.Collections.Generic.List[PSCustomObject]]::new() # Check all current items against baseline foreach ($checkId in $currentMap.Keys) { $current = $currentMap[$checkId] $baseline = $baselineMap[$checkId] if (-not $baseline) { $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' } elseif ($prevStatus -ne $currStatus) { 'Modified' } else { 'Modified' # Same status, different value } $driftResults.Add([PSCustomObject]@{ CheckId = $checkId Setting = $current.Setting Category = $current.Category Section = $current._Section ChangeType = $changeType PreviousStatus = $prevStatus CurrentStatus = $currStatus PreviousValue = $prevValue CurrentValue = $currValue }) } # Check for removed items (in baseline but not in current) 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) } |