src/Public/Baseline/Compare-FotonBaseline.ps1
|
function Compare-FotonBaseline { <# .SYNOPSIS Field-level diff of two Foton baseline JSON files (offline). .DESCRIPTION Refuses to diff if BaselineSchemaVersion does not match. Emits change records and Add-FotonFinding rows (Tier 1 for the fact of change). Writes baseline_diff.csv beside the difference JSON (for Analyze ingest). Temporal (same hostname) vs fleet (host vs golden) only changes wording. WhyItMatters includes the provider FalsePositives string when registered. .PARAMETER ReferencePath Older / golden baseline. .PARAMETER DifferencePath Newer / candidate baseline. .EXAMPLE Compare-FotonBaseline -ReferencePath D:\Baselines\web01-day0.json -DifferencePath D:\Baselines\web01-day30.json Temporal diff. New Administrators members are High attention, not proof of compromise. .PARAMETER OutputPath HTML compare report path. Default: Foton_BaselineCompare.html beside the difference JSON. .PARAMETER NoHtml Skip the HTML file (tests and New-FotonBaselineCompareReport re-entry). .PARAMETER NoCsv Skip writing baseline_diff.csv beside the difference JSON. .OUTPUTS Change records (RecordType, Identity, ChangeType, Field, OldValue, NewValue). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$ReferencePath, [Parameter(Mandatory)][string]$DifferencePath, [string]$OutputPath, [switch]$NoHtml, [switch]$NoCsv ) $ReferencePath = Get-FotonBaselineJsonPath -Path $ReferencePath $DifferencePath = Get-FotonBaselineJsonPath -Path $DifferencePath $ref = Get-Content -LiteralPath $ReferencePath -Raw | ConvertFrom-Json $dif = Get-Content -LiteralPath $DifferencePath -Raw | ConvertFrom-Json $sv = $ref.Header.BaselineSchemaVersion if ($sv -ne $dif.Header.BaselineSchemaVersion) { throw ("Baseline schema mismatch: reference {0} vs difference {1}. Refuse to diff." -f $sv, $dif.Header.BaselineSchemaVersion) } $mode = 'Fleet' if ($ref.Header.Hostname -eq $dif.Header.Hostname) { $mode = 'Temporal' } if ($null -eq $script:FotonSession) { Initialize-FotonSession } if (-not $NoHtml) { Write-FotonBanner -Kind 'Compare' } $script:FotonSession.Findings = New-Object System.Collections.ArrayList $provByType = @{} foreach ($p in @($ref.Providers)) { $provByType[$p.RecordType] = $p } $changes = @() $types = @($ref.Records.PSObject.Properties.Name) foreach ($t in @($dif.Records.PSObject.Properties.Name)) { if ($types -notcontains $t) { $types += $t } } foreach ($type in ($types | Sort-Object)) { $meta = $provByType[$type] if (-not $meta) { $meta = [PSCustomObject]@{ IdentityFields = @('Name'); ComparedFields = @() } } $idFields = @($meta.IdentityFields) $cmpFields = @($meta.ComparedFields) $refMap = @{} foreach ($r in @($ref.Records.$type)) { $refMap[(Get-FotonBaselineIdentity -Record $r -IdentityFields $idFields)] = $r } $difMap = @{} foreach ($r in @($dif.Records.$type)) { $difMap[(Get-FotonBaselineIdentity -Record $r -IdentityFields $idFields)] = $r } foreach ($id in ($difMap.Keys | Sort-Object)) { if (-not $refMap.ContainsKey($id)) { $changes += [PSCustomObject]@{ RecordType = $type; Identity = $id; ChangeType = 'Added' Field = ''; OldValue = ''; NewValue = '' } continue } $a = $refMap[$id]; $b = $difMap[$id] foreach ($f in $cmpFields) { $ov = [string]$a.$f; $nv = [string]$b.$f if ($ov -cne $nv) { $changes += [PSCustomObject]@{ RecordType = $type; Identity = $id; ChangeType = 'Modified' Field = $f; OldValue = $ov; NewValue = $nv } } } } foreach ($id in ($refMap.Keys | Sort-Object)) { if (-not $difMap.ContainsKey($id)) { $changes += [PSCustomObject]@{ RecordType = $type; Identity = $id; ChangeType = 'Removed' Field = ''; OldValue = ''; NewValue = '' } } } } foreach ($c in $changes) { $sev = Get-FotonBaselineChangeSeverity -Change $c $title = "{0} {1}" -f $c.ChangeType, $c.RecordType $obs = "Identity '{0}'." -f $c.Identity if ($c.ChangeType -eq 'Modified') { $obs = "Identity '{0}' field {1}: '{2}' -> '{3}'." -f $c.Identity, $c.Field, $c.OldValue, $c.NewValue } $fp = Get-FotonBaselineFalsePositives -RecordType $c.RecordType $why = ("{0} mode ({1} vs {2}). A baseline diff records change between two snapshots. It does not establish when the change occurred, who made it, or that the host is compromised." -f ` $mode, $ref.Header.CapturedUtc, $dif.Header.CapturedUtc) if ($fp) { $why = $why + (' Common benign sources for {0}: {1}' -f $c.RecordType, $fp) } else { $why = $why + ' Software version bumps and GPO are common benign sources.' } $next = 'Confirm against change control or the role golden image. Prefer a provisioning-time reference stored off-host.' if ($mode -eq 'Temporal') { $next = 'Confirm against change control for this host. Prefer a provisioning-time day0 stored off-host.' } elseif ($mode -eq 'Fleet') { $next = 'Confirm against the role golden image and expected local variance (hostname, SID, joined domain).' } Add-FotonFinding -Severity $sev -Category 'Baseline' -Tier 1 -Title $title ` -Observation $obs ` -WhyItMatters $why ` -Artifact (Split-Path $DifferencePath -Leaf) ` -NextStep $next } # Always persist the field-level change table beside the difference baseline so # operators can copy it into a triage package as analysis/baseline_diff.csv # for Invoke-FotonAnalysis (High-attention surfaces only there). if (-not $NoCsv) { $csvPath = Join-Path (Split-Path -Parent $DifferencePath) 'baseline_diff.csv' try { if ($changes.Count -gt 0) { $changes | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 } } catch { } } Write-FotonStatus ("Baseline diff ({0}): {1} change(s), {2} finding(s)" -f $mode, $changes.Count, $script:FotonSession.Findings.Count) 'Good' if (-not $NoHtml) { if (-not $OutputPath) { $OutputPath = Join-Path (Split-Path -Parent $DifferencePath) 'Foton_BaselineCompare.html' } New-FotonBaselineCompareReport -Changes $changes -Mode $mode ` -ReferenceHeader $ref.Header -DifferenceHeader $dif.Header ` -OutputPath $OutputPath | Out-Null } return $changes } |