Scripts/Compare-DocsFIDOChanges.ps1
|
<# .SYNOPSIS Compares FIDO vendor table changes between GitHub commits and generates markdown reports. .DESCRIPTION Fetches the Microsoft Entra FIDO hardware vendor documentation, detects changes between commits, and generates a timestamped markdown file with Added/Modified/Removed keys. Supports comparing against local Assets/FidoKeys.json for validation. Implements state tracking to avoid reprocessing and automatically detects multiple commits between runs. .PARAMETER Owner GitHub repository owner (default: MicrosoftDocs) .PARAMETER Repo GitHub repository name (default: entra-docs) .PARAMETER Commit Specific commit SHA to compare (auto-detected if omitted) .PARAMETER ParentCommit Parent commit SHA (auto-detected if omitted) .PARAMETER FilePath Path to vendor table in repo (default: docs/identity/authentication/concept-fido2-hardware-vendor.md) .PARAMETER LatestMeaningful Skip metadata-only commits and find the latest commit with actual table changes .PARAMETER FIDODiff Compare changes against local Assets/FidoKeys.json and show Match/Mismatch/Missing status .PARAMETER ForceDiff Force reprocessing even if commit was already processed (testing use) .EXAMPLE ./Compare-DocsFIDOChanges.ps1 -LatestMeaningful -FIDODiff Finds latest meaningful commit, compares against local data, generates timestamped report .EXAMPLE ./Compare-DocsFIDOChanges.ps1 -ForceDiff Reprocess latest commit, ignoring state file .NOTES Generates FIDO_KEY_CHANGES_<timestamp>[_<sha>][_comparediff].md in workspace root. Maintains .fido_diff_state.json for state tracking across runs. #> param( [string]$Owner = 'MicrosoftDocs', [string]$Repo = 'entra-docs', [string]$Commit, [string]$ParentCommit, [string]$FilePath = 'docs/identity/authentication/concept-fido2-hardware-vendor.md', [string]$OutputMarkdown, [switch]$LatestMeaningful, [switch]$FIDODiff, [switch]$ForceDiff ) # Generate timestamped filename if not specified if (-not $OutputMarkdown) { $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' $rootPath = Split-Path $PSScriptRoot -Parent $suffix = $FIDODiff ? '_comparediff' : '' $OutputMarkdown = Join-Path $rootPath "FIDO_KEY_CHANGES_${timestamp}${suffix}.md" } function Get-LatestCommit { param([string]$Owner,[string]$Repo,[string]$Path) $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=1" $headers = @{ 'User-Agent' = 'EntraFIDOFinder' } try { $resp = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop if ($resp -and $resp.Count -gt 0) { return $resp[0].sha } throw "No commits found for path: $Path" } catch { throw "Failed to get latest commit: $_" } } function Get-MeaningfulCommit { param([string]$Owner,[string]$Repo,[string]$Path,[int]$Scan = 20) $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=$Scan" $headers = @{ 'User-Agent' = 'EntraFIDOFinder' } $commits = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop if (-not $commits) { throw 'No commits returned when scanning for meaningful changes.' } foreach ($c in $commits) { $sha = $c.sha $parentSha = $c.parents[0].sha $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $sha -Path $Path $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $parentSha -Path $Path try { $oldRows = Parse-Table -content $oldContent $newRows = Parse-Table -content $newContent } catch { continue } $comparison = Compare-Entries -old $oldRows -new $newRows if ($comparison.Added.Count -or $comparison.Removed.Count -or $comparison.Modified.Count) { return @{ Commit = $sha; Parent = $parentSha; Comparison = $comparison } } } throw "No meaningful table changes found in the last $Scan commits." } function Get-ParentCommit { param([string]$Owner,[string]$Repo,[string]$Commit) $url = "https://api.github.com/repos/$Owner/$Repo/commits/$Commit" $headers = @{ 'User-Agent' = 'EntraFIDOFinder' } try { $resp = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop return $resp.parents[0].sha } catch { Write-Warning "Failed to get parent commit via GitHub API: $_" return $null } } function Get-RawFile { param([string]$Owner,[string]$Repo,[string]$Commit,[string]$Path) $rawUrl = "https://raw.githubusercontent.com/$Owner/$Repo/$Commit/$Path" $headers = @{ 'User-Agent' = 'EntraFIDOFinder' } try { (Invoke-WebRequest -Uri $rawUrl -Headers $headers -UseBasicParsing -ErrorAction Stop).Content } catch { throw "Failed to download raw file: $rawUrl. $_" } } function Normalize-Icon { param([string]$text) if (-not $text) { return '' } $t = $text.Trim() # Map common check/close representations if ($t -match '✅|&#x?2705;|✔') { return '✅' } if ($t -match '❌|❌') { return '❌' } return $t } function Parse-Table { param([string]$content) $lines = $content -split "`n" $start = $lines | Select-String -Pattern '^Description\|AAGUID\|Bio\|USB\|NFC\|BLE$' | Select-Object -First 1 if (-not $start) { throw 'Could not find table header: Description|AAGUID|Bio|USB|NFC|BLE' } $startIndex = $start.LineNumber # Skip the delimiter line (---|---|...) $i = $startIndex while ($i -lt $lines.Count -and ($lines[$i] -match '^-+\|-+\|-+')) { $i++ } $results = @() for ($j = $i; $j -lt $lines.Count; $j++) { $line = $lines[$j].Trim() if ($line -match '^##\s' -or [string]::IsNullOrWhiteSpace($line)) { break } if ($line -notmatch '\|') { continue } $parts = $line -split '\|' # Expect 6 columns if ($parts.Count -lt 6) { continue } $desc = $parts[0].Trim() $aaguid = $parts[1].Trim() $bio = Normalize-Icon $parts[2] $usb = Normalize-Icon $parts[3] $nfc = Normalize-Icon $parts[4] $ble = Normalize-Icon $parts[5] if ($aaguid -and $aaguid -match '^[0-9a-fA-F\-]{36}$') { $results += [pscustomobject]@{ Description = $desc AAGUID = $aaguid.ToLower() Bio = $bio USB = $usb NFC = $nfc BLE = $ble } } } return $results } function Compare-Entries { param($old,$new) $oldMap = @{} foreach ($o in $old) { $oldMap[$o.AAGUID] = $o } $newMap = @{} foreach ($n in $new) { $newMap[$n.AAGUID] = $n } $added = @() foreach ($n in $new) { if (-not $oldMap.ContainsKey($n.AAGUID)) { $added += $n } } $removed = @() foreach ($o in $old) { if (-not $newMap.ContainsKey($o.AAGUID)) { $removed += $o } } $modified = @() foreach ($aag in $newMap.Keys) { if ($oldMap.ContainsKey($aag)) { $n = $newMap[$aag] $o = $oldMap[$aag] $changes = @() if ($o.Description -ne $n.Description) { $changes += @('Description') } if ($o.Bio -ne $n.Bio) { $changes += @('Bio') } if ($o.USB -ne $n.USB) { $changes += @('USB') } if ($o.NFC -ne $n.NFC) { $changes += @('NFC') } if ($o.BLE -ne $n.BLE) { $changes += @('BLE') } if ($changes.Count -gt 0) { $modified += [pscustomobject]@{ AAGUID = $aag Old = $o New = $n Changed = $changes } } } } return [pscustomobject]@{ Added = $added; Removed = $removed; Modified = $modified } } function Build-LocalMap { param([string]$LocalPath) if (-not (Test-Path $LocalPath)) { throw "Local FIDO keys file not found: $LocalPath" } $json = Get-Content -Path $LocalPath -Raw | ConvertFrom-Json if (-not $json.keys) { throw "Local FIDO keys file missing 'keys' property" } $map = @{} foreach ($k in $json.keys) { if ($k.AAGUID) { $map[$k.AAGUID.ToLower()] = $k } } return $map } function Compare-WithLocal { param($comparison,$localMap) $diff = [System.Collections.ArrayList]::new() foreach ($n in $comparison.Added) { $entry = if ($localMap.ContainsKey($n.AAGUID)) { $localMap[$n.AAGUID] } else { $null } $status = 'Missing' if ($entry) { if ($entry.Bio -eq $n.Bio -and $entry.USB -eq $n.USB -and $entry.NFC -eq $n.NFC -and $entry.BLE -eq $n.BLE) { $status = 'Match' } else { $status = 'Mismatch' } } [void]$diff.Add([pscustomobject]@{ Type='Added'; AAGUID=$n.AAGUID; Description=$n.Description; Status=$status }) } foreach ($r in $comparison.Removed) { $entry = if ($localMap.ContainsKey($r.AAGUID)) { $localMap[$r.AAGUID] } else { $null } $status = $entry ? 'Mismatch (still present)' : 'Match (removed)' [void]$diff.Add([pscustomobject]@{ Type='Removed'; AAGUID=$r.AAGUID; Description=$r.Description; Status=$status }) } foreach ($m in $comparison.Modified) { $entry = if ($localMap.ContainsKey($m.AAGUID)) { $localMap[$m.AAGUID] } else { $null } if (-not $entry) { [void]$diff.Add([pscustomobject]@{ Type='Modified'; AAGUID=$m.AAGUID; Description=$m.New.Description; Status='Missing' }) continue } $matchesNew = ($entry.Bio -eq $m.New.Bio -and $entry.USB -eq $m.New.USB -and $entry.NFC -eq $m.New.NFC -and $entry.BLE -eq $m.New.BLE) $status = $matchesNew ? 'Match' : 'Mismatch' [void]$diff.Add([pscustomobject]@{ Type='Modified'; AAGUID=$m.AAGUID; Description=$m.New.Description; Status=$status }) } return $diff } function Get-StatePath { $rootPath = Split-Path $PSScriptRoot -Parent return Join-Path $rootPath '.fido_diff_state.json' } function Load-State { $path = Get-StatePath if (Test-Path $path) { try { return Get-Content -Path $path -Raw | ConvertFrom-Json } catch { return $null } } return $null } function Save-State { param([string]$Commit) $path = Get-StatePath $obj = @{ lastCommit = $Commit } $obj | ConvertTo-Json | Set-Content -Path $path -Encoding UTF8 } function Get-CommitsBetween { param([string]$Owner,[string]$Repo,[string]$Path,[string]$OldCommit,[string]$NewCommit) # Get all commits for the path between old and new $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=100" $headers = @{ 'User-Agent' = 'EntraFIDOFinder' } try { $allCommits = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop if (-not $allCommits) { return @() } $commits = @() $found = $false foreach ($c in $allCommits) { if ($c.sha -eq $NewCommit) { $found = $true } if ($found -and $c.sha -ne $OldCommit) { $commits += $c } if ($c.sha -eq $OldCommit) { break } } return $commits } catch { Write-Warning "Failed to fetch commits between $OldCommit and ${NewCommit}: $_" return @() } } function Write-Markdown { param($result,[string]$output,[string]$oldLabel,[string]$newLabel,$diffSummary) $added = $result.Added $removed = $result.Removed $modified = $result.Modified $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("# FIDO Key Changes - $oldLabel → $newLabel") [void]$sb.AppendLine() [void]$sb.AppendLine("**Source:** MicrosoftDocs/entra-docs commit $newLabel ") [void]$sb.AppendLine("**MDS Version:** $oldLabel → $newLabel") [void]$sb.AppendLine() [void]$sb.AppendLine("## Summary") [void]$sb.AppendLine("- **$($added.Count) keys added**") [void]$sb.AppendLine("- **$($removed.Count) keys removed**") [void]$sb.AppendLine("- **$($modified.Count) keys modified**") [void]$sb.AppendLine() [void]$sb.AppendLine("---") [void]$sb.AppendLine() [void]$sb.AppendLine("## ✅ Keys Added ($($added.Count))") [void]$sb.AppendLine() [void]$sb.AppendLine('| Description | AAGUID | Bio | USB | NFC | BLE |') [void]$sb.AppendLine('|------------|---------|-----|-----|-----|-----|') foreach ($n in ($added | Sort-Object Description)) { [void]$sb.AppendLine("| $($n.Description) | $($n.AAGUID) | $($n.Bio) | $($n.USB) | $($n.NFC) | $($n.BLE) |") } [void]$sb.AppendLine() [void]$sb.AppendLine('---') [void]$sb.AppendLine() [void]$sb.AppendLine("## ⚠️ Keys Modified ($($modified.Count))") [void]$sb.AppendLine() foreach ($m in ($modified | Sort-Object AAGUID)) { [void]$sb.AppendLine("### $($m.New.Description)") [void]$sb.AppendLine("- **AAGUID:** $($m.AAGUID)") foreach ($field in $m.Changed) { $oldVal = $m.Old.$field $newVal = $m.New.$field [void]$sb.AppendLine("- **${field}:** ${oldVal} -> ${newVal}") } [void]$sb.AppendLine() } [void]$sb.AppendLine('---') [void]$sb.AppendLine() [void]$sb.AppendLine("## ❌ Keys Removed ($($removed.Count))") [void]$sb.AppendLine() if ($removed.Count -eq 0) { [void]$sb.AppendLine('No keys were removed in this update.') } else { [void]$sb.AppendLine('| Description | AAGUID | Bio | USB | NFC | BLE |') [void]$sb.AppendLine('|------------|---------|-----|-----|-----|-----|') foreach ($r in ($removed | Sort-Object Description)) { [void]$sb.AppendLine("| $($r.Description) | $($r.AAGUID) | $($r.Bio) | $($r.USB) | $($r.NFC) | $($r.BLE) |") } } if ($diffSummary) { [void]$sb.AppendLine() [void]$sb.AppendLine('---') [void]$sb.AppendLine() [void]$sb.AppendLine('## CompareDiff Results') [void]$sb.AppendLine() [void]$sb.AppendLine('| Type | AAGUID | Description | Status |') [void]$sb.AppendLine('|------|--------|-------------|--------|') foreach ($d in $diffSummary) { [void]$sb.AppendLine("| $($d.Type) | $($d.AAGUID) | $($d.Description) | $($d.Status) |") } } Set-Content -Path $output -Value $sb.ToString() -Encoding UTF8 } # Auto-detect latest commit if not specified if ($LatestMeaningful) { Write-Host "Scanning recent commits for meaningful table changes..." -ForegroundColor Yellow $meaningful = Get-MeaningfulCommit -Owner $Owner -Repo $Repo -Path $FilePath $Commit = $meaningful.Commit $ParentCommit = $meaningful.Parent $comparison = $meaningful.Comparison } else { if (-not $Commit) { Write-Host "No commit specified. Fetching latest commit for '$FilePath'..." -ForegroundColor Yellow $Commit = Get-LatestCommit -Owner $Owner -Repo $Repo -Path $FilePath Write-Host "Latest commit: $Commit" -ForegroundColor Green } if (-not $ParentCommit) { $ParentCommit = Get-ParentCommit -Owner $Owner -Repo $Repo -Commit $Commit if (-not $ParentCommit) { throw 'Parent commit could not be determined. Provide -ParentCommit explicitly.' } } Write-Host "Comparing $Owner/$Repo file '$FilePath' between commits" -ForegroundColor Cyan Write-Host "New: $Commit" -ForegroundColor Cyan Write-Host "Parent: $ParentCommit" -ForegroundColor Cyan $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $Commit -Path $FilePath $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $ParentCommit -Path $FilePath $oldRows = Parse-Table -content $oldContent $newRows = Parse-Table -content $newContent $comparison = Compare-Entries -old $oldRows -new $newRows } if (-not $ForceDiff) { $state = Load-State if ($state -and $state.lastCommit -eq $Commit) { Write-Host "Already processed latest commit $Commit. Use -ForceDiff to rerun." -ForegroundColor Yellow return } # Check for intermediate commits if ($state -and $state.lastCommit -ne $Commit) { Write-Host "Detected new commits since last run. Processing each..." -ForegroundColor Yellow $intermediateCommits = Get-CommitsBetween -Owner $Owner -Repo $Repo -Path $FilePath -OldCommit $state.lastCommit -NewCommit $Commit if ($intermediateCommits.Count -gt 0) { foreach ($ic in $intermediateCommits) { $icSha = $ic.sha $icParent = $ic.parents[0].sha Write-Host "Processing intermediate commit $icSha..." -ForegroundColor Cyan $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $icSha -Path $FilePath $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $icParent -Path $FilePath $oldRows = Parse-Table -content $oldContent $newRows = Parse-Table -content $newContent $icComparison = Compare-Entries -old $oldRows -new $newRows if ($icComparison.Added.Count -or $icComparison.Removed.Count -or $icComparison.Modified.Count) { $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' $rootPath = Split-Path $PSScriptRoot -Parent $suffix = $FIDODiff ? '_comparediff' : '' $icOutput = Join-Path $rootPath "FIDO_KEY_CHANGES_${timestamp}_${icSha.Substring(0,7)}${suffix}.md" if ($FIDODiff) { $localPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Assets/FidoKeys.json' $localMap = Build-LocalMap -LocalPath $localPath $icDiffSummary = Compare-WithLocal -comparison $icComparison -localMap $localMap Write-Markdown -result $icComparison -output $icOutput -oldLabel $icParent -newLabel $icSha -diffSummary $icDiffSummary } else { Write-Markdown -result $icComparison -output $icOutput -oldLabel $icParent -newLabel $icSha -diffSummary $null } Write-Host "Intermediate commit written to: $icOutput" -ForegroundColor Green } } } } } # The labels can reflect MDS version if available in content; we keep commit shas for clarity if ($FIDODiff) { $localPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Assets/FidoKeys.json' $localMap = Build-LocalMap -LocalPath $localPath $diffSummary = Compare-WithLocal -comparison $comparison -localMap $localMap Write-Markdown -result $comparison -output $OutputMarkdown -oldLabel $ParentCommit -newLabel $Commit -diffSummary $diffSummary } else { Write-Markdown -result $comparison -output $OutputMarkdown -oldLabel $ParentCommit -newLabel $Commit -diffSummary $null } Save-State -Commit $Commit Write-Host "Markdown written to: $OutputMarkdown" -ForegroundColor Green |