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
}