Core/Compare-SecurityBaseline.ps1
|
function Compare-SecurityBaseline { <# .SYNOPSIS Compares a saved security baseline against a current security state and reports drift .DESCRIPTION Loads a previously saved security baseline from JSON (for example the output of Get-SecurityBaseline or Invoke-SecurityAssessment piped to ConvertTo-Json) and compares it against a second state. The current state is either loaded from a second JSON file (-CurrentPath) or captured live via Get-SecurityBaseline when -CurrentPath is omitted. Every differing property is classified as Improved, Degraded, New, Removed, or Changed, and a summary with an overall trend is produced. Results can optionally be exported as JSON (full result) and CSV (changes only) via -OutputPath. .PARAMETER BaselinePath Path to the previously saved baseline JSON file (mandatory) .PARAMETER CurrentPath Path to a JSON file containing the current state. When omitted, the current state is captured live by calling Get-SecurityBaseline .PARAMETER OutputPath Directory where the comparison result (JSON) and the list of changes (CSV) are written .EXAMPLE Compare-SecurityBaseline -BaselinePath "C:\Audits\baseline.json" Compares the saved baseline against the live system state .EXAMPLE Compare-SecurityBaseline -BaselinePath "C:\Audits\baseline.json" -CurrentPath "C:\Audits\current.json" -OutputPath "C:\Audits\Drift" Compares two saved states and exports the result as JSON and CSV .OUTPUTS PSCustomObject with CompareDate, Categories, Changes, and Summary properties #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string]$BaselinePath, [Parameter(Position = 1)] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string]$CurrentPath, [Parameter(Position = 2)] [string]$OutputPath ) begin { Write-Host "Starting Security Baseline Comparison..." -ForegroundColor Cyan # Properties that hold volatile timestamps are excluded from drift detection $script:noisePattern = '(^|\.)(ScanDate|CollectionDate|CompareDate|LastBootUpTime|LocalDateTime|InstallDate|LastUpdateCheck)$' $changes = @() $unchangedCount = 0 $categories = @() # Flattens a nested PSCustomObject/array graph into a path -> leaf-value map function ConvertTo-FlatMap { param( [object]$Node, [string]$Path, [hashtable]$Map ) if ($null -eq $Node) { $Map[$Path] = '(null)' return } if ($Node -is [System.Management.Automation.PSCustomObject]) { foreach ($prop in $Node.PSObject.Properties) { $childPath = if ($Path) { "$Path.$($prop.Name)" } else { $prop.Name } ConvertTo-FlatMap -Node $prop.Value -Path $childPath -Map $Map } } elseif ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { $items = @($Node) if ($items.Count -eq 0) { $Map[$Path] = '(empty)' } for ($i = 0; $i -lt $items.Count; $i++) { ConvertTo-FlatMap -Node $items[$i] -Path "$Path[$i]" -Map $Map } } else { $Map[$Path] = "$Node" } } # Maps boolean-like strings to numbers so direction of change can be determined function ConvertTo-ComparableValue { param([string]$Value) if ($null -eq $Value) { return $null } switch -Regex ($Value) { '^(True|On|Enabled|Yes|1)$' { return 1.0 } '^(False|Off|Disabled|No|0)$' { return 0.0 } } $number = 0.0 if ([double]::TryParse($Value, [ref]$number)) { return $number } return $null } # Decides whether a changed value is an improvement or a degradation function Get-ChangeDirection { param( [string]$Path, [string]$BaselineValue, [string]$CurrentValue ) $goodDirection = 0 # 1 = higher is better, -1 = lower is better, 0 = unknown switch -Regex ($Path) { 'PendingUpdates|Vulnerabilit|Issue|Error|Failed|Risk|Threat|Exposure' { $goodDirection = -1; break } 'Enabled|Protection|EnableLUA|Encryption|Score|Secure|Compliant|Health' { $goodDirection = 1; break } } if ($goodDirection -eq 0) { return 'Changed' } $baseNumber = ConvertTo-ComparableValue -Value $BaselineValue $currentNumber = ConvertTo-ComparableValue -Value $CurrentValue if ($null -eq $baseNumber -or $null -eq $currentNumber) { return 'Changed' } $delta = $currentNumber - $baseNumber if ($delta -eq 0) { return 'Changed' } if (($delta -gt 0 -and $goodDirection -eq 1) -or ($delta -lt 0 -and $goodDirection -eq -1)) { return 'Improved' } return 'Degraded' } } process { try { # Load the saved baseline Write-Host "Loading baseline from: $BaselinePath" -ForegroundColor Yellow try { $baselineJson = Get-Content -Path $BaselinePath -Raw -Encoding UTF8 -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($baselineJson)) { throw "Baseline file '$BaselinePath' is empty or contains only whitespace - not valid JSON" } $baseline = $baselineJson | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error "Could not load baseline file '$BaselinePath': $_" throw } # Load or capture the current state $currentSource = "Live capture (Get-SecurityBaseline)" $current = $null if ($CurrentPath) { $currentSource = $CurrentPath Write-Host "Loading current state from: $CurrentPath" -ForegroundColor Yellow try { $currentJson = Get-Content -Path $CurrentPath -Raw -Encoding UTF8 -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($currentJson)) { throw "Current state file '$CurrentPath' is empty or contains only whitespace - not valid JSON" } $current = $currentJson | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error "Could not load current state file '$CurrentPath': $_" throw } } else { Write-Host "Capturing current state via Get-SecurityBaseline..." -ForegroundColor Yellow if (-not (Get-Command -Name Get-SecurityBaseline -ErrorAction SilentlyContinue)) { Write-Error "Get-SecurityBaseline is not available. Provide -CurrentPath or load the WindowsSecurityAudit module." throw "Get-SecurityBaseline not found" } try { $current = Get-SecurityBaseline -ErrorAction Stop } catch { Write-Warning "Live capture with Get-SecurityBaseline failed: $_" throw } } # Flatten both states into comparable maps Write-Host "Comparing security states..." -ForegroundColor Yellow $baselineMap = @{} $currentMap = @{} ConvertTo-FlatMap -Node $baseline -Path '' -Map $baselineMap ConvertTo-FlatMap -Node $current -Path '' -Map $currentMap # Build the list of compared categories (top-level property names) $categorySet = @{} foreach ($key in @($baselineMap.Keys) + @($currentMap.Keys)) { if ($key -match $script:noisePattern) { continue } $topLevel = ($key -split '\.')[0] -replace '\[.*$', '' if ($topLevel) { $categorySet[$topLevel] = $true } } $categories = @($categorySet.Keys | Sort-Object) # Detect changed, new, and removed properties foreach ($key in @($baselineMap.Keys | Sort-Object)) { if ($key -match $script:noisePattern) { continue } $baselineValue = $baselineMap[$key] if (-not $currentMap.ContainsKey($key)) { $changes += [PSCustomObject]@{ Property = $key BaselineValue = $baselineValue CurrentValue = '(missing)' ChangeType = 'Removed' } continue } $currentValue = $currentMap[$key] if ($baselineValue -eq $currentValue) { $unchangedCount++ } else { $changes += [PSCustomObject]@{ Property = $key BaselineValue = $baselineValue CurrentValue = $currentValue ChangeType = (Get-ChangeDirection -Path $key -BaselineValue $baselineValue -CurrentValue $currentValue) } } } foreach ($key in @($currentMap.Keys | Sort-Object)) { if ($key -match $script:noisePattern) { continue } if (-not $baselineMap.ContainsKey($key)) { $changes += [PSCustomObject]@{ Property = $key BaselineValue = '(missing)' CurrentValue = $currentMap[$key] ChangeType = 'New' } } } $changes = @($changes | Sort-Object ChangeType, Property) } catch { Write-Error "Error during security baseline comparison: $_" throw } } end { # Build summary $improvedCount = @($changes | Where-Object { $_.ChangeType -eq 'Improved' }).Count $degradedCount = @($changes | Where-Object { $_.ChangeType -eq 'Degraded' }).Count $newCount = @($changes | Where-Object { $_.ChangeType -eq 'New' }).Count $removedCount = @($changes | Where-Object { $_.ChangeType -eq 'Removed' }).Count $overallTrend = 'Stable' if ($improvedCount -gt 0 -and $degradedCount -gt 0) { $overallTrend = 'Mixed' } elseif ($improvedCount -gt $degradedCount) { $overallTrend = 'Improved' } elseif ($degradedCount -gt $improvedCount) { $overallTrend = 'Degraded' } elseif ($newCount -gt 0 -or $removedCount -gt 0) { $overallTrend = 'Changed' } $summary = [PSCustomObject]@{ ImprovedCount = $improvedCount DegradedCount = $degradedCount NewCount = $newCount RemovedCount = $removedCount UnchangedCount = $unchangedCount TotalChanges = @($changes).Count OverallTrend = $overallTrend } $result = [PSCustomObject]@{ CompareDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" ComputerName = $env:COMPUTERNAME BaselineSource = $BaselinePath CurrentSource = $currentSource Categories = $categories Changes = $changes Summary = $summary } # Display results Write-Host "`nComparison Results" -ForegroundColor Cyan Write-Host "------------------" -ForegroundColor Cyan if ($changes.Count -eq 0) { Write-Host "No drift detected - current state matches the baseline." -ForegroundColor Green } else { foreach ($change in $changes) { $color = switch ($change.ChangeType) { 'Improved' { 'Green' } 'Degraded' { 'Red' } default { 'Yellow' } } Write-Host ("[{0}] {1}: '{2}' -> '{3}'" -f $change.ChangeType, $change.Property, $change.BaselineValue, $change.CurrentValue) -ForegroundColor $color } } Write-Host ("Summary: {0} improved, {1} degraded, {2} new, {3} removed, {4} unchanged - Trend: {5}" -f $improvedCount, $degradedCount, $newCount, $removedCount, $unchangedCount, $overallTrend) -ForegroundColor Cyan # Export results if path specified if ($OutputPath) { try { if (-not (Test-Path $OutputPath)) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $jsonFile = Join-Path -Path $OutputPath -ChildPath "CompareResult_$timestamp.json" $csvFile = Join-Path -Path $OutputPath -ChildPath "CompareChanges_$timestamp.csv" $result | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonFile -Encoding UTF8 $changes | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 Write-Host "Results exported to: $jsonFile" -ForegroundColor Green Write-Host "Changes exported to: $csvFile" -ForegroundColor Green } catch { Write-Warning "Could not export comparison results to '$OutputPath': $_" } } return $result } } |