Public/Compare-IncidentCapsule.ps1
|
function Compare-IncidentCapsule { <# .SYNOPSIS Compares two Incident Capsule directories and reports evidence changes. .DESCRIPTION Diffs a curated set of stable evidence types (services, scheduled tasks, installed software, autorun registry values, local users and groups, certificate trust stores, and system drivers) between a baseline capsule and a current capsule of the same host. Each evidence type reports added, removed, and changed records keyed by a stable identity. Volatile evidence (running processes, live network endpoints, event summaries, the derived timeline) is intentionally excluded so the result surfaces persistence-, account-, and inventory-level change rather than normal runtime churn. Both inputs must be capsule directories. Verify and extract archives with Test-IncidentCapsuleIntegrity first. The comparison report is written outside both capsules so neither sealed capsule is modified. .PARAMETER BaselinePath Path to the earlier (baseline) capsule directory. .PARAMETER CurrentPath Path to the later (current) capsule directory. .PARAMETER DestinationPath Optional output file for the JSON comparison report. Defaults to '<current>.comparison.json' beside the current capsule. The destination must not already exist and must be outside both capsule directories. .EXAMPLE Compare-IncidentCapsule -BaselinePath $baseline.WorkingDirectory -CurrentPath $incident.WorkingDirectory .OUTPUTS IncidentCapsule.ComparisonResult #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$BaselinePath, [Parameter(Mandatory, Position = 1)] [string]$CurrentPath, [string]$DestinationPath ) $baselineResolved = (Resolve-Path -LiteralPath $BaselinePath -ErrorAction Stop).Path $currentResolved = (Resolve-Path -LiteralPath $CurrentPath -ErrorAction Stop).Path foreach ($candidate in @($baselineResolved, $currentResolved)) { if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { throw "Path '$candidate' is not a capsule directory. Verify and extract an archive with Test-IncidentCapsuleIntegrity before comparing." } } $baselineRoot = Find-ICManifestRoot -Path $baselineResolved $currentRoot = Find-ICManifestRoot -Path $currentResolved $destination = if ($PSBoundParameters.ContainsKey('DestinationPath') -and -not [string]::IsNullOrWhiteSpace($DestinationPath)) { [System.IO.Path]::GetFullPath($DestinationPath) } else { "$currentRoot.comparison.json" } foreach ($root in @($baselineRoot, $currentRoot)) { $prefix = $root + [System.IO.Path]::DirectorySeparatorChar if ($destination -eq $root -or $destination.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "DestinationPath '$destination' is inside a capsule directory; sealed capsules must not be modified." } } if (Test-Path -LiteralPath $destination) { throw "DestinationPath '$destination' already exists." } $baselineId = $null $currentId = $null try { $baselineId = [string]((Get-Content -LiteralPath (Join-Path $baselineRoot 'metadata/capsule.json') -Raw -Encoding UTF8 | ConvertFrom-Json).capsule.id) } catch { $baselineId = $null } try { $currentId = [string]((Get-Content -LiteralPath (Join-Path $currentRoot 'metadata/capsule.json') -Raw -Encoding UTF8 | ConvertFrom-Json).capsule.id) } catch { $currentId = $null } $sections = New-Object System.Collections.ArrayList $totalAdded = 0 $totalRemoved = 0 $totalChanged = 0 foreach ($spec in Get-ICComparisonSpec) { $baselineData = Get-ICCapsuleEvidenceData -CapsuleRoot $baselineRoot -Source $spec.Source $currentData = Get-ICCapsuleEvidenceData -CapsuleRoot $currentRoot -Source $spec.Source if (-not $baselineData.Available -or -not $currentData.Available) { [void]$sections.Add([pscustomobject][ordered]@{ key = $spec.Key label = $spec.Label source = $spec.Source comparable = $false reason = if (-not $baselineData.Available) { 'Baseline evidence file is missing or unreadable.' } else { 'Current evidence file is missing or unreadable.' } baselineCount = @($baselineData.Data).Count currentCount = @($currentData.Data).Count added = @() removed = @() changed = @() }) continue } $diff = Compare-ICEvidenceRecord -Baseline @($baselineData.Data) -Current @($currentData.Data) -IdentityFields $spec.Identity $totalAdded += @($diff.Added).Count $totalRemoved += @($diff.Removed).Count $totalChanged += @($diff.Changed).Count [void]$sections.Add([pscustomobject][ordered]@{ key = $spec.Key label = $spec.Label source = $spec.Source comparable = $true identityFields = @($spec.Identity) baselineCount = @($baselineData.Data).Count currentCount = @($currentData.Data).Count addedCount = @($diff.Added).Count removedCount = @($diff.Removed).Count changedCount = @($diff.Changed).Count added = @($diff.Added) removed = @($diff.Removed) changed = @($diff.Changed) }) } $report = [ordered]@{ '$schema' = $script:ICComparisonSchema schemaVersion = $script:ICSchemaVersion generatedAtUtc = [datetime]::UtcNow.ToString('o') tool = [ordered]@{ name = $script:ICName; version = $script:ICVersion } baseline = [ordered]@{ path = $baselineRoot; capsuleId = $baselineId } current = [ordered]@{ path = $currentRoot; capsuleId = $currentId } summary = [ordered]@{ comparableSections = @($sections | Where-Object comparable -eq $true).Count skippedSections = @($sections | Where-Object comparable -eq $false).Count totalAdded = $totalAdded totalRemoved = $totalRemoved totalChanged = $totalChanged } sections = @($sections) } $destinationDirectory = Split-Path -Parent $destination if (-not [string]::IsNullOrWhiteSpace($destinationDirectory) -and -not (Test-Path -LiteralPath $destinationDirectory)) { New-Item -ItemType Directory -Path $destinationDirectory -Force -ErrorAction Stop | Out-Null } [void](Write-ICJsonFile -Path $destination -InputObject $report -Depth 40) return [pscustomobject][ordered]@{ PSTypeName = 'IncidentCapsule.ComparisonResult' ReportPath = $destination BaselinePath = $baselineRoot CurrentPath = $currentRoot BaselineCapsuleId = $baselineId CurrentCapsuleId = $currentId ComparableSections = @($sections | Where-Object comparable -eq $true).Count SkippedSections = @($sections | Where-Object comparable -eq $false).Count TotalAdded = $totalAdded TotalRemoved = $totalRemoved TotalChanged = $totalChanged Sections = @($sections) } } |