Scripts/Merge-GHFidoData.ps1

<#
.SYNOPSIS
    Merges FIDO key data from a URL with existing JSON data and logs any changes.

.DESCRIPTION
    Fetches FIDO key data from the specified URL and merges it with local JSON data.
    Handles updates, additions, and removals of keys, validates vendors, and logs changes.
    Updates metadata and environment variables for GitHub Actions.

.PARAMETER Url
    The URL to fetch FIDO key data from.
    Defaults to Microsoft's hardware vendor page.

.PARAMETER JsonFilePath
    The path to the local JSON file containing existing FIDO key data.
    Default is 'Assets/FidoKeys.json'.

.PARAMETER MarkdownFilePath
    The path to the markdown file for logging merge results.
    Default is 'merge_log.md'.

.PARAMETER DetailedLogFilePath
    The path to the detailed log file.
    Default is 'detailed_log.txt'.

.PARAMETER ValidVendorsFilePath
    The path to the JSON file containing valid vendors.
    Default is 'Assets/valid_vendors.json'.

.EXAMPLE
    Merge-GHFidoData

.NOTES
    Author: Clayton Tyger
    Date: 12-01-2024
#>


Function Merge-GHFidoData {
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]$Url = "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-fido2-hardware-vendor",
        [Parameter()]
        [string]$JsonFilePath = "Assets/FidoKeys.json",
        [Parameter()]
        [string]$MarkdownFilePath = "merge_log.md",
        [Parameter()]
        [string]$DetailedLogFilePath = "detailed_log.txt",
        [Parameter()]
        [string]$ValidVendorsFilePath = "Assets/valid_vendors.json"
    )

    $ErrorActionPreference = 'Stop'
    
    # Load existing JSON data
    try {
        if (-not (Test-Path -Path $JsonFilePath)) {
            $jsonData = @{
                metadata = @{
                    databaseLastChecked = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
                    databaseLastUpdated = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
                }
                keys     = @()
            }
        }
        else {
            $jsonData = Get-Content -Raw -Path $JsonFilePath | ConvertFrom-Json
        }
    }
    catch {
        Write-Error "Failed to load JSON data: $_"
        return
    }

    # Load valid vendors data
    try {
        $ValidVendors = (Get-Content -Raw -Path $ValidVendorsFilePath | ConvertFrom-Json).vendors
    }
    catch {
        Write-Error "Failed to load valid vendors data: $_"
        return
    }

    # Initialize variables
    $changesDetected = [ref]$false
    $updateDatabaseLastUpdated = $false
    $changesAreSame = $false
    $keysNowValid = New-Object System.Collections.ArrayList
    $issueEntries = New-Object System.Collections.ArrayList
    $loggedInvalidVendors = New-Object System.Collections.ArrayList
    $currentLogEntries = New-Object System.Collections.ArrayList

    # Import the Test-GHValidVendor function
    . "$PSScriptRoot\Test-GHValidVendor.ps1"

    # Initialize merged data
    $mergedData = @{
        metadata = @{
            databaseLastChecked = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
            databaseLastUpdated = $jsonData.metadata.databaseLastUpdated
        }
        keys     = @()
    }

    # Initialize an empty hashtable
    $jsonDataByAAGUID = @{}

    # Populate the hashtable with AAGUIDs as keys and single items as values
    foreach ($key in $jsonData.keys) {
        $jsonDataByAAGUID[$key.AAGUID] = $key
    }

    # Fetch data from URL
    try {
        $urlData = Export-GHEntraFido -Url $Url
    }
    catch {
        Write-Error "Failed to fetch data from URL: $_"
        return
    }

    # Index URL data by AAGUID
    $urlDataByAAGUID = $urlData | Group-Object -AsHashTable -Property AAGUID

    # Prepare for logging
    $logDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

    # Parse existing log content
    $existingMarkdownContent = if (Test-Path -Path $MarkdownFilePath) {
        Get-Content -Raw -Path $MarkdownFilePath
    }
    else {
        ""
    }

    $existingDetailedLogContent = if (Test-Path -Path $DetailedLogFilePath) {
        Get-Content -Raw -Path $DetailedLogFilePath
    }
    else {
        ""
    }

    # Initialize content
    $markdownContent = New-Object System.Collections.ArrayList
    $detailedLogContent = New-Object System.Collections.ArrayList
    $detailedLogContent.Add("Detailed Log - $logDate") # Initialize with the log date
    $envFilePath = "$PSScriptRoot/env_vars.txt"

    # Parse existing markdown content to extract last log entries
    if ($existingMarkdownContent -ne "") {
        # Regex to match each log entry
        $pattern = "(?ms)^# Merge Log - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*(.*?)(?=^# Merge Log - \d{4}-\d{2}-\d{2}|\z)"
        $matches = [regex]::Matches($existingMarkdownContent, $pattern)
        if ($matches.Count -gt 0) {
            # The first match is the most recent log entries
            $lastLogEntriesSection = $matches[0].Groups[1].Value
            $existingLogEntries = $lastLogEntriesSection -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
        }
    }

    # Initialize existingLogEntries as an array even if it's null or empty
    $existingLogEntries = @($existingLogEntries)

    # Collect changes in a separate variable
    $detailedChanges = @()

    # Merge data and handle vendors
    foreach ($aaguid in $urlDataByAAGUID.Keys) {
        $urlItem = $urlDataByAAGUID[$aaguid]
        $description = $urlItem.Description
        $vendor = ""

        if ($jsonDataByAAGUID.ContainsKey($aaguid)) {
            # Existing entry
            $existingItem = $jsonDataByAAGUID[$aaguid]
            $vendor = $existingItem.Vendor
            $vendorRef = [ref]$vendor

            # Create a hashtable with parameters
            $ValidVendorParams = @{
                vendor               = $vendorRef
                description          = $description
                aaguid               = $aaguid
                ValidVendors         = $ValidVendors
                markdownContent      = $markdownContent
                detailedLogContent   = $detailedLogContent
                loggedInvalidVendors = $loggedInvalidVendors
                issueEntries         = $issueEntries
                existingLogEntries   = $existingLogEntries
                changesDetected      = $changesDetected
                IsNewEntry           = $false
                currentLogEntries    = $currentLogEntries
            }

            # Call the function with splatting
            $validVendor = Test-GHValidVendor @ValidVendorParams
            $vendor = $vendorRef.Value

            # Access $existingItem.Version
            $existingVersion = $existingItem.Version

            # Get the latest version from metadataStatement.authenticatorGetInfo.versions
            $latestVersion = $null
            if ($existingItem.metadataStatement?.authenticatorGetInfo?.versions) {
                $latestVersion = $existingItem.metadataStatement.authenticatorGetInfo.versions[-1]
            }

            # Compare and update the Version property if needed
            if ($latestVersion -and $existingVersion -ne $latestVersion) {
                # Update the Version property
                $existingItem.Version = $latestVersion
                $changesDetected.Value = $true
                $updateDatabaseLastUpdated = $true

                # Log the change
                $logEntry = "Updated 'Version' for AAGUID '$aaguid' from '$existingVersion' to '$latestVersion'."
                $currentLogEntries.Add($logEntry)
                $detailedChanges += $logEntry  # Collect changes separately
            }

            # Check for changes in specific properties
            $propertiesToCheck = @('Description', 'Bio', 'USB', 'NFC', 'BLE')
            foreach ($property in $propertiesToCheck) {
                $existingValue = $existingItem.$property
                $newValue = $urlItem.$property
                if ($existingValue -ne $newValue) {
                    $existingItem.$property = $newValue
                    $changesDetected.Value = $true
                    $updateDatabaseLastUpdated = $true
                    $logEntry = "Updated '$property' for AAGUID '$aaguid' from '$existingValue' to '$newValue'."
                    $currentLogEntries.Add($logEntry)
                    $detailedChanges += $logEntry  # Collect changes separately
                }
            }

            # Normalize ValidVendor values to strings
            $existingValidVendor = [string]$existingItem.ValidVendor
            $newValidVendor = [string]$validVendor

            # Update ValidVendor status if changed
            if ($existingValidVendor -ne $newValidVendor) {
                $existingItem.ValidVendor = $newValidVendor
                $changesDetected.Value = $true
                $updateDatabaseLastUpdated = $true

                if ($newValidVendor -eq 'Yes') {
                    $keysNowValid.Add($aaguid)
                    $logEntry = "Vendor '$vendor' for description '$description' has become valid."
                    $currentLogEntries.Add($logEntry)
                    $detailedChanges += $logEntry
                    # Add logic to close the corresponding GitHub issue if it exists
                    $issueTitle = "Invalid Vendor Detected for AAGUID $aaguid : $vendor"
                    $existingIssue = $issueEntries | Where-Object { $_ -match [regex]::Escape($issueTitle) }
                    if ($existingIssue) {
                        $issueEntries.Add("$issueTitle|CLOSE")
                    }
                }
                elseif ($newValidVendor -eq 'No') {
                    $logEntry = "Vendor '$vendor' for description '$description' has become invalid."
                    $currentLogEntries.Add($logEntry)
                    $detailedChanges += $logEntry
                    # Create an issue when a vendor becomes invalid
                    $issueTitle = "Vendor Became Invalid for AAGUID $aaguid : $vendor"
                    $issueBody = $logEntry

                    # Check if the issue already exists
                    $existingIssue = $issueEntries | Where-Object { $_ -match [regex]::Escape($issueTitle) }
                    if (-not $existingIssue) {
                        $issueEntries.Add("$issueTitle|$issueBody|InvalidVendor")
                    }
                }
            }

            # Add updated item to merged data
            $mergedData.keys += $existingItem
        }
        else {
            # New entry
            $vendorRef = [ref]$vendor
            $ValidVendorParams = @{
                vendor               = $vendorRef
                description          = $description
                aaguid               = $aaguid
                ValidVendors         = $ValidVendors
                markdownContent      = $markdownContent
                detailedLogContent   = $detailedLogContent
                loggedInvalidVendors = $loggedInvalidVendors
                issueEntries         = $issueEntries
                existingLogEntries   = $existingLogEntries
                changesDetected      = $changesDetected
                IsNewEntry           = $true
                currentLogEntries    = $currentLogEntries
            }

            # Call the function with splatting
            $validVendor = Test-GHValidVendor @ValidVendorParams
            $vendor = $vendorRef.Value

            $newItem = [pscustomobject]@{
                Vendor                 = $vendor
                Description            = $description
                AAGUID                 = $aaguid
                Bio                    = $urlItem.Bio
                USB                    = $urlItem.USB
                NFC                    = $urlItem.NFC
                BLE                    = $urlItem.BLE
                Version                = $urlItem.Version
                ValidVendor            = $validVendor
                authenticatorGetInfo   = $urlItem.authenticatorGetInfo
                statusReports          = $urlItem.statusReports
                timeOfLastStatusChange = $urlItem.timeOfLastStatusChange
            }
            $mergedData.keys += $newItem
            $changesDetected.Value = $true
            $updateDatabaseLastUpdated = $true

            # Log new entry if vendor is valid
            if ($validVendor -eq 'Yes') {
                $logEntry = "Added new entry for AAGUID '$aaguid' with description '$description' and vendor '$vendor'."
                $currentLogEntries.Add($logEntry)
                $detailedChanges += $logEntry  # Collect changes separately
            }
            # Note: Invalid vendor logging for new entries is handled inside Test-GHValidVendor
        }
    }

    # Handle removed entries
    foreach ($aaguid in $jsonDataByAAGUID.Keys) {
        if (-not $urlDataByAAGUID.ContainsKey($aaguid)) {
            $removedItem = $jsonDataByAAGUID[$aaguid]
            $logEntry = "Entry removed for description '$($removedItem.Description)' with AAGUID '$aaguid'."
            $currentLogEntries.Add($logEntry)
            $detailedChanges += $logEntry  # Collect changes separately
            $changesDetected.Value = $true
            $updateDatabaseLastUpdated = $true
        }
    }

    # Update metadata
    if ($updateDatabaseLastUpdated) {
        $mergedData.metadata.databaseLastUpdated = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
    }
    $mergedData.metadata.databaseLastChecked = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

    # Sort and write the merged data
    $mergedData.keys = $mergedData.keys | Sort-Object Vendor
    $jsonOutput = $mergedData | ConvertTo-Json -Depth 10
    Set-Content -Path $JsonFilePath -Value $jsonOutput -Encoding utf8

    # Compare current log entries with last run's log entries
    if ($changesDetected.Value) {
        # Ensure both arrays are initialized
        $normalizedExistingLogEntries = @($existingLogEntries | ForEach-Object { $_.Trim() })
        $normalizedCurrentLogEntries = @($currentLogEntries | ForEach-Object { $_.Trim() })

        $differences = Compare-Object -ReferenceObject $normalizedExistingLogEntries -DifferenceObject $normalizedCurrentLogEntries | Where-Object { $_.SideIndicator -ne '==' }

        if ($differences.Count -eq 0) {
            $changesAreSame = $true
        }
    }

    # Adjust logging based on whether changes are the same as last run
    if ($changesDetected.Value -and -not $changesAreSame) {
        # Only update merge_log.md when there are new changes different from last run
        Write-Host "New changes detected. Updating merge_log.md."
        # Update merge_log.md
        $newMergeContent = "# Merge Log - $logDate`n`n" + 
                            ($currentLogEntries -join "`n`n") + 
        "`n`n`n" + 
        $existingMarkdownContent.Trim()
        Set-Content -Path $MarkdownFilePath -Value $newMergeContent -Encoding utf8
        # Update detailed_log.txt
        # Clear the existing content
        $detailedLogContent.Clear()
        $detailedLogContent.Add("DETAILED LOG - $logDate")
        $detailedLogContent.Add("")
        # Ensure collected changes are added to $detailedLogContent
        for ($i = 0; $i -lt $detailedChanges.Count; $i++) {
            $detailedLogContent.Add($detailedChanges[$i])
            if ($i -lt ($detailedChanges.Count - 1)) {
                $detailedLogContent.Add("")  # Add an empty line between entries
            }
        }
        $detailedLogContent = $detailedLogContent | ForEach-Object { $_.TrimEnd("`n", "`r") }
        # Add extra newline between entries
        $newDetailedContent = ($detailedLogContent -join "`n") + "`n`n`n`n" + $existingDetailedLogContent.TrimStart("`n", "`r")
        Set-Content -Path $DetailedLogFilePath -Value $newDetailedContent -Encoding utf8
    }
    else {
        # Do not update merge_log.md
        if (-not $changesDetected.Value) {
            Write-Host "No changes detected. Not updating merge_log.md."
        }
        elseif ($changesAreSame) {
            Write-Host "Changes are the same as the last run. Not updating merge_log.md."
        }
        # Update detailed_log.txt with "No changes detected during this run."
        $detailedLogContent.Clear()
        $detailedLogContent.Add("DETAILED LOG - $logDate")
        $detailedLogContent.Add("")
        $detailedLogContent.Add("No changes detected during this run.")
        # Add consistent spacing between entries
        $newDetailedContent = ($detailedLogContent -join "`n") + "`n`n`n`n" + $existingDetailedLogContent.TrimStart("`n", "`r")
        Set-Content -Path $DetailedLogFilePath -Value $newDetailedContent -Encoding utf8
    }

    # Update environment variables for GitHub Actions
    if ($issueEntries -and $issueEntries.Count -gt 0) {
        $issueEntriesString = $issueEntries -join "`n"
        # Escape special characters for GitHub Actions
        if ($null -ne $issueEntriesString -and $issueEntriesString -ne "") {
            $issueEntriesEscaped = $issueEntriesString.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A').Replace("'", '%27').Replace('"', '%22')
            "ISSUE_ENTRIES=$issueEntriesEscaped" | Out-File -FilePath $envFilePath -Encoding utf8 -Append
        }
    }

    # Write keys now valid to the environment variables file
    if ($keysNowValid -and $keysNowValid.Count -gt 0) {
        $keysNowValidString = $keysNowValid | Select-Object -Unique | Sort-Object
        $keysNowValidString = $keysNowValidString -join "`n"
        # Escape special characters for GitHub Actions
        if ($null -ne $keysNowValidString -and $keysNowValidString -ne "") {
            $keysNowValidEscaped = $keysNowValidString.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A').Replace("'", '%27').Replace('"', '%22')
            "KEYS_NOW_VALID=$keysNowValidEscaped" | Out-File -FilePath $envFilePath -Encoding utf8 -Append
        }
    }
}
Merge-GHFidoData