Scripts/Merge-GHFidoData.ps1

<#
.SYNOPSIS
    Merges FIDO data from a JSON file and a URL source, updating and validating entries.

.DESCRIPTION
    The `Merge-GHFidoData` function merges FIDO key data from a specified URL and a local JSON file. It validates the vendors, updates the entries, and logs the changes. The function also handles invalid vendors and prepares issue entries for further action.

.PARAMETER Url
    The URL to fetch the FIDO key data from. Default is "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-fido2-hardware-vendor".

.PARAMETER JsonFilePath
    The path to the local JSON file containing the 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 for logging detailed merge results. Default is "detailed_log.txt".

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

.EXAMPLE
    Merge-GHFidoData -Url "https://example.com/fido-keys" -JsonFilePath "Assets/FidoKeys.json" -MarkdownFilePath "merge_log.md" -DetailedLogFilePath "detailed_log.txt" -ValidVendorsFilePath "Assets/valid_vendors.json"
    Merges FIDO key data from the specified URL and local JSON file, validates the vendors, updates the entries, and logs the changes.

.NOTES
    The function reads the list of valid vendors from the specified JSON file and uses the `Test-GHValidVendor` function to validate the vendors. It logs the changes to the specified markdown and detailed log files.
#>

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
    }

    $ValidVendorsData = Get-Content -Raw -Path $ValidVendorsFilePath | ConvertFrom-Json
    $ValidVendors = $ValidVendorsData.vendors

    # Initialize variables
    $keysNowValid = [ref]@()
    $vendorsNowValid = [ref]@()
    $changesDetected = [ref]$false
    $updateDatabaseLastUpdated = $false

    # 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     = @()
    }

    # Index existing JSON data by AAGUID
    $jsonDataByAAGUID = @{}
    foreach ($jsonItem in $jsonData.keys) {
        $jsonDataByAAGUID[$jsonItem.AAGUID] = $jsonItem
    }

    # 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 = @{}
    foreach ($urlItem in $urlData) {
        $urlDataByAAGUID[$urlItem.AAGUID] = $urlItem
    }

    $logDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

    # Read 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 = [ref]@()
    $detailedLogContent = [ref]@("Detailed Log - $logDate")
    $issueEntries = [ref]@()
    $envFilePath = "$PSScriptRoot/env_vars.txt"
    $loggedInvalidVendors = [ref]@()
    $existingLogEntries = @()
    $currentLogEntries = @()

    # Parse existing markdown content to extract last log entries
    if ($existingMarkdownContent -ne "") {
        # Split the content into sections based on the header
        $logSections = $existingMarkdownContent -split "(?m)^# Merge Log - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"
        if ($logSections.Count -gt 1) {
            # The first element may be empty due to split behavior
            $lastLogEntries = $logSections[1] -split "`r?`n"
            $existingLogEntries = $lastLogEntries | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
        }
    }

    # Merge data and handle vendors
    foreach ($urlItem in $urlData) {
        $hash = [ordered]@{}
        foreach ($prop in $urlItem.PSObject.Properties) {
            $hash[$prop.Name] = $prop.Value
        }

        $aaguid = $hash['AAGUID']
        $description = $hash['Description']
        $vendor = ""  # Vendor from URL data is always blank

        # Check if AAGUID exists in existing JSON data
        if ($jsonDataByAAGUID.ContainsKey($aaguid)) {
            # Existing entry - use Vendor from JSON data
            $existingItem = $jsonDataByAAGUID[$aaguid]

            # Ensure mutable
            $existingHash = [ordered]@{}
            foreach ($prop in $existingItem.PSObject.Properties) {
                $existingHash[$prop.Name] = $prop.Value
            }

            # Get Vendor from existing JSON data
            $vendor = $existingHash['Vendor']  # This may be null or empty

            # Store the original vendor before validation
            $originalVendor = $vendor

            # Prepare vendor as [ref] to allow updates from the function
            $vendorRef = [ref]$vendor

            # Validate Vendor
            $validVendor = Test-GHValidVendor -vendor $vendorRef -description $description -aaguid $aaguid -ValidVendors $ValidVendors -markdownContent $markdownContent -detailedLogContent $detailedLogContent -loggedInvalidVendors $loggedInvalidVendors -issueEntries $issueEntries -existingLogEntries $existingLogEntries -changesDetected $changesDetected

            # Update vendor variable if it was changed in the function
            $vendor = $vendorRef.Value

            # Check if vendor has changed (correction occurred)
            if ($vendor -ne $originalVendor) {
                $changesDetected.Value = $true
                $updateDatabaseLastUpdated = $true
            }

            # Retrieve the existing 'ValidVendor' status
            $existingValidVendor = if ($existingItem) { $existingItem.ValidVendor } else { 'No' }

            # Compare the existing and new 'ValidVendor' status
            if (($existingValidVendor -eq 'No' -or [string]::IsNullOrEmpty($existingValidVendor)) -and $validVendor -eq 'Yes') {
                # Key has become valid
                $keysNowValid.Value += $aaguid
                $changesDetected.Value = $true
                $updateDatabaseLastUpdated = $true

                # Prepare log entry
                $logEntry = "Vendor '$vendor' for description '$description' has become valid."
                $currentLogEntries += $logEntry
                $detailedLogContent.Value += "`n$logEntry"
            }

            # Check for changes in specific properties
            $propertiesToCheck = @('Description', 'Bio', 'USB', 'NFC', 'BLE')
            foreach ($property in $propertiesToCheck) {
                if ($existingHash[$property] -ne $hash[$property]) {
                    $changesDetected.Value = $true
                    $updateDatabaseLastUpdated = $true  # Update when property changes
                    $logEntry = "Property '$property' for AAGUID '$aaguid' changed from '$($existingHash[$property])' to '$($hash[$property])'."
                    $currentLogEntries += $logEntry
                    $detailedLogContent.Value += "`n$logEntry"
                }
            }

            # Update the existing hash with the desired property order
            $existingHash = [ordered]@{
                Vendor      = $vendor
                Description = $description
                AAGUID      = $aaguid
                Bio         = $hash['Bio']
                USB         = $hash['USB']
                NFC         = $hash['NFC']
                BLE         = $hash['BLE']
                ValidVendor = $validVendor
            }

            $mergedData.keys += [PSCustomObject]$existingHash
        }
        else {
            # New entry
            $vendor = ""  # Leave Vendor as empty
            $validVendor = 'No'

            # Create a hashtable for new item with desired property order
            $itemHash = [ordered]@{
                Vendor      = $vendor
                Description = $description
                AAGUID      = $hash['AAGUID']
                Bio         = $hash['Bio']
                USB         = $hash['USB']
                NFC         = $hash['NFC']
                BLE         = $hash['BLE']
                ValidVendor = $validVendor
            }

            $mergedData.keys += [PSCustomObject]$itemHash
            $changesDetected.Value = $true
            $updateDatabaseLastUpdated = $true

        }

        # Collect current invalid vendors for comparison
        if ($validVendor -eq "No") {
            $invalidVendorEntry = "Invalid vendor detected for AAGUID '$aaguid' with description '$description'. Vendor '$vendor' is not in the list of valid vendors."
            $currentLogEntries += $invalidVendorEntry
            $detailedLogContent.Value += "`n$invalidVendorEntry"
        }
    }

    # Handle removed entries (no issues created)
    foreach ($jsonItem in $jsonData.keys) {
        if (-not $urlDataByAAGUID.ContainsKey($jsonItem.AAGUID)) {
            $logEntry = "Entry removed for description '$($jsonItem.Description)'"
            $currentLogEntries += $logEntry
            $detailedLogContent.Value += "`n$logEntry"
            $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')

    # Write the merged data
    $jsonOutput = $mergedData | ConvertTo-Json -Depth 10

    # Write to the JSON file
    Set-Content -Path $JsonFilePath -Value $jsonOutput -Encoding utf8

    # Compare current log entries with existing log entries
    $newChanges = $false
    if ($currentLogEntries.Count -ne $existingLogEntries.Count) {
        $newChanges = $true
    }
    else {
        for ($i = 0; $i -lt $currentLogEntries.Count; $i++) {
            if ($currentLogEntries[$i] -ne $existingLogEntries[$i]) {
                $newChanges = $true
                break
            }
        }
    }

    # Write to merge_log.md only if there are new changes
    if ($newChanges -and $currentLogEntries.Count -gt 0) {
        $newMergeContent = "# Merge Log - $logDate`n`n" + 
                            ($currentLogEntries -join "`n`n") + 
        "`n`n`n" + 
        $existingMarkdownContent.Trim()
        Set-Content -Path $MarkdownFilePath -Value $newMergeContent -Encoding utf8
    }
    else {
        Write-Host "No new entries to add to merge_log.md."
    }

    # If no entries were added, add a default message
    if ($detailedLogContent.Value.Count -eq 1) {
        $detailedLogContent.Value += "`nNo changes detected during this run."
    }

    # Always write to detailed_log.txt
    $detailedLogContent.Value = $detailedLogContent.Value.TrimEnd("`n", "`r")
    $newDetailedContent = $detailedLogContent.Value + "`n`n" + $existingDetailedLogContent.TrimStart("`n", "`r")

    Set-Content -Path $DetailedLogFilePath -Value $newDetailedContent -Encoding utf8

    # Write issue entries to the environment variables file
    if ($issueEntries.Value -and $issueEntries.Value.Count -gt 0) {
        $issueEntriesString = $issueEntries.Value -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.Value -and $keysNowValid.Value.Count -gt 0) {
        $keysNowValidString = $keysNowValid.Value | 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
        }
    }
}

# Call the function
Merge-GHFidoData