From 7670ab886cd7dfefaacd586f10a2cb31e3e3f50a Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:59:04 -0700 Subject: [PATCH] Adds Secure Boot deployment diagnostics Includes Secure Boot support in the PE image so firmware variables can be inspected during imaging. Captures baseline, post-apply, and final boot evidence for firmware state, storage layout, boot files, and boot configuration to explain UEFI boot failures and highlight likely dbx blocks or boot entry issues. --- FFUDevelopment/BuildFFUVM.ps1 | 2 + FFUDevelopment/Create-PEMedia.ps1 | 1 + .../WinPEDeployFFUFiles/ApplyFFU.ps1 | 1573 ++++++++++++++++- 3 files changed, 1571 insertions(+), 5 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index a312c76..c43bcd2 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -3414,6 +3414,8 @@ function New-PEMedia { "en-us\WinPE-Scripting_en-us.cab", "WinPE-PowerShell.cab", "en-us\WinPE-PowerShell_en-us.cab", + "WinPE-SecureBootCmdlets.cab", + "en-us\WinPE-SecureBootCmdlets_en-us.cab", "WinPE-StorageWMI.cab", "en-us\WinPE-StorageWMI_en-us.cab", "WinPE-DismCmdlets.cab", diff --git a/FFUDevelopment/Create-PEMedia.ps1 b/FFUDevelopment/Create-PEMedia.ps1 index 2b641af..5a774cc 100644 --- a/FFUDevelopment/Create-PEMedia.ps1 +++ b/FFUDevelopment/Create-PEMedia.ps1 @@ -115,6 +115,7 @@ function New-PEMedia { "en-us\WinPE-Scripting_en-us.cab", "WinPE-PowerShell.cab", "en-us\WinPE-PowerShell_en-us.cab", + "WinPE-SecureBootCmdlets.cab", "WinPE-StorageWMI.cab", "en-us\WinPE-StorageWMI_en-us.cab", "WinPE-DismCmdlets.cab", diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 index 6e48255..73f0549 100644 --- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 +++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 @@ -787,6 +787,1554 @@ function Get-AvailableDriveLetter { return $null } +function New-SecureBootDiagnosticsFolder { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$UsbDrive + ) + + # Create a per-run diagnostics folder on the deployment media. + try { + $diagnosticsRoot = Join-Path -Path $UsbDrive -ChildPath 'SecureBootDiagnostics' + New-Item -Path $diagnosticsRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null + + $folderName = 'Run_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + $diagnosticsPath = Join-Path -Path $diagnosticsRoot -ChildPath $folderName + $suffix = 1 + while (Test-Path -Path $diagnosticsPath) { + $diagnosticsPath = Join-Path -Path $diagnosticsRoot -ChildPath ("{0}_{1}" -f $folderName, $suffix) + $suffix++ + } + + New-Item -Path $diagnosticsPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + WriteLog "Secure Boot diagnostics folder: $diagnosticsPath" + return $diagnosticsPath + } + catch { + WriteLog "Warning: Failed to create Secure Boot diagnostics folder. $($_.Exception.Message)" + return $null + } +} + +function New-DiagnosticsStageFolder { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$DiagnosticsRoot, + [Parameter(Mandatory = $true)] + [string]$StageName + ) + + # Create a stage-specific folder for collected artifacts. + try { + if ([string]::IsNullOrWhiteSpace($DiagnosticsRoot)) { + return $null + } + + $stagePath = Join-Path -Path $DiagnosticsRoot -ChildPath $StageName + New-Item -Path $stagePath -ItemType Directory -Force -ErrorAction Stop | Out-Null + return $stagePath + } + catch { + WriteLog "Warning: Failed to create diagnostics stage folder '$StageName'. $($_.Exception.Message)" + return $null + } +} + +function Write-DiagnosticsTextFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter()] + [AllowNull()] + [object]$Content + ) + + # Persist text diagnostics without affecting deployment flow. + try { + $directoryPath = Split-Path -Path $FilePath -Parent + if (-not [string]::IsNullOrWhiteSpace($directoryPath)) { + New-Item -Path $directoryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + + if ($null -eq $Content) { + $Content = '' + } + + Set-Content -Path $FilePath -Value $Content -Encoding UTF8 -Force -ErrorAction Stop + } + catch { + WriteLog "Warning: Failed to write diagnostics file '$FilePath'. $($_.Exception.Message)" + } +} + +function Get-ByteArraySha256 { + [CmdletBinding()] + param( + [byte[]]$Bytes + ) + + # Calculate a stable hash for raw EFI variable data. + if ($null -eq $Bytes) { + return $null + } + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return (($sha256.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') }) -join '') + } + finally { + $sha256.Dispose() + } +} + +function Get-ByteArrayAsciiMarker { + [CmdletBinding()] + param( + [byte[]]$Bytes + ) + + # Look for obvious ASCII markers inside EFI variable data. + if ($null -eq $Bytes -or $Bytes.Length -eq 0) { + return $null + } + + $asciiText = [System.Text.Encoding]::ASCII.GetString($Bytes) + $markers = @( + 'Windows UEFI CA 2023', + 'Windows UEFI CA 2011', + 'Microsoft Corporation UEFI CA 2011', + 'Microsoft Corporation KEK CA 2011', + 'Microsoft Windows Production PCA 2011', + 'Microsoft' + ) + + foreach ($marker in $markers) { + if ($asciiText -match [regex]::Escape($marker)) { + return $marker + } + } + + return $null +} + +function Open-EspPartitionAccess { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [int]$DiskNumber + ) + + # Assign a temporary drive letter to the EFI system partition when needed. + try { + $espPartition = Get-Partition -DiskNumber $DiskNumber -ErrorAction Stop | Where-Object { + $_.Type -eq 'System' -or $_.GptType -eq '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + } | Select-Object -First 1 + + if ($null -eq $espPartition) { + WriteLog "Warning: EFI system partition not found on disk $DiskNumber." + return $null + } + + if ($espPartition.DriveLetter) { + $driveLetter = $espPartition.DriveLetter.ToString().ToUpperInvariant() + return [PSCustomObject]@{ + DiskNumber = $DiskNumber + PartitionNumber = $espPartition.PartitionNumber + DriveLetter = $driveLetter + DrivePath = "$driveLetter`:\" + RemoveAccessPath = $false + } + } + + $driveLetter = Get-AvailableDriveLetter + if ($null -eq $driveLetter) { + WriteLog 'Warning: No drive letters are available to mount the EFI system partition.' + return $null + } + + Set-Partition -InputObject $espPartition -NewDriveLetter $driveLetter -ErrorAction Stop + WriteLog "Assigned temporary drive letter $driveLetter`: to the EFI system partition." + + return [PSCustomObject]@{ + DiskNumber = $DiskNumber + PartitionNumber = $espPartition.PartitionNumber + DriveLetter = $driveLetter + DrivePath = "$driveLetter`:\" + RemoveAccessPath = $true + } + } + catch { + WriteLog "Warning: Failed to access the EFI system partition on disk $DiskNumber. $($_.Exception.Message)" + return $null + } +} + +function Close-EspPartitionAccess { + [CmdletBinding()] + param( + [Parameter()] + [pscustomobject]$EspAccess + ) + + # Remove a temporary EFI access path after diagnostics complete. + if ($null -eq $EspAccess -or $EspAccess.RemoveAccessPath -ne $true) { + return + } + + try { + Get-Partition -DiskNumber $EspAccess.DiskNumber -ErrorAction Stop | + Where-Object { $_.PartitionNumber -eq $EspAccess.PartitionNumber } | + Remove-PartitionAccessPath -AccessPath "$($EspAccess.DriveLetter):" -ErrorAction Stop + + WriteLog "Removed temporary drive letter $($EspAccess.DriveLetter): from the EFI system partition." + } + catch { + WriteLog "Warning: Failed to remove temporary EFI access path $($EspAccess.DriveLetter):. $($_.Exception.Message)" + } +} + +function Save-StorageSnapshot { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$StagePath, + [Parameter(Mandatory = $true)] + [int]$DiskNumber + ) + + # Capture disk, partition, and volume state for the selected target disk. + try { + $storagePath = Join-Path -Path $StagePath -ChildPath 'Storage' + New-Item -Path $storagePath -ItemType Directory -Force -ErrorAction Stop | Out-Null + + $disk = Get-Disk -Number $DiskNumber -ErrorAction Stop + $partitions = @(Get-Partition -DiskNumber $DiskNumber -ErrorAction Stop | Sort-Object PartitionNumber) + $partitionTable = @( + $partitions | Select-Object ` + PartitionNumber, + DriveLetter, + Type, + GptType, + @{ Name = 'SizeGB'; Expression = { if ($_.Size) { [math]::Round(($_.Size / 1GB), 2) } else { $null } } } + ) + + $volumeRecords = @() + foreach ($partition in $partitions) { + $partitionVolume = $null + + if ($partition.DriveLetter) { + try { + $partitionVolume = Get-Volume -DriveLetter $partition.DriveLetter -ErrorAction Stop + } + catch { + $partitionVolume = $null + } + } + + $volumeRecords += [PSCustomObject]@{ + PartitionNumber = $partition.PartitionNumber + DriveLetter = $partition.DriveLetter + FileSystem = if ($partitionVolume) { $partitionVolume.FileSystem } else { $null } + FileSystemLabel = if ($partitionVolume) { $partitionVolume.FileSystemLabel } else { $null } + HealthStatus = if ($partitionVolume) { $partitionVolume.HealthStatus } else { $null } + SizeGB = if ($partitionVolume -and $partitionVolume.Size) { [math]::Round(($partitionVolume.Size / 1GB), 2) } else { $null } + FreeGB = if ($partitionVolume -and $partitionVolume.SizeRemaining) { [math]::Round(($partitionVolume.SizeRemaining / 1GB), 2) } else { $null } + } + } + + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $storagePath -ChildPath 'disk.txt') -Content (($disk | Format-List * | Out-String).Trim()) + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $storagePath -ChildPath 'partitions.txt') -Content (($partitionTable | Format-Table -AutoSize | Out-String).Trim()) + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $storagePath -ChildPath 'volumes.txt') -Content (($volumeRecords | Format-Table -AutoSize | Out-String).Trim()) + + WriteLog "Storage snapshot [$StageName]: Disk=$DiskNumber; Partitions=$($partitions.Count); Volumes=$($volumeRecords.Count)." + } + catch { + WriteLog "Warning: Failed to capture storage snapshot [$StageName] for disk $DiskNumber. $($_.Exception.Message)" + } +} + +function Get-CertificateSha256 { + [CmdletBinding()] + param( + [Parameter()] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + # Hash a certificate's DER bytes for comparison with db/dbx entries. + if ($null -eq $Certificate) { + return $null + } + + return Get-ByteArraySha256 -Bytes $Certificate.RawData +} + +function Get-ByteArrayHexString { + [CmdletBinding()] + param( + [byte[]]$Bytes, + [string]$Delimiter = '' + ) + + # Convert bytes to uppercase hexadecimal for readable reports and comparisons. + if ($null -eq $Bytes) { + return $null + } + + return (($Bytes | ForEach-Object { $_.ToString('X2') }) -join $Delimiter) +} + +function Get-EfiGuidString { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [byte[]]$Bytes, + [Parameter(Mandatory = $true)] + [int]$Offset + ) + + # Read an EFI_GUID from a byte array at the specified offset. + if ($Offset -lt 0 -or ($Offset + 16) -gt $Bytes.Length) { + return $null + } + + $guidBytes = [byte[]]::new(16) + [Array]::Copy($Bytes, $Offset, $guidBytes, 0, 16) + + try { + return ([System.Guid]::new($guidBytes)).Guid + } + catch { + return $null + } +} + +function Get-EfiSignatureTypeName { + [CmdletBinding()] + param( + [string]$SignatureTypeGuid + ) + + # Map well-known EFI signature type GUIDs to friendly names. + if ([string]::IsNullOrWhiteSpace($SignatureTypeGuid)) { + return 'UNKNOWN' + } + + switch ($SignatureTypeGuid.ToLowerInvariant()) { + 'a5c059a1-94e4-4aa7-87b5-ab155c2bf072' { return 'EFI_CERT_X509' } + 'c1c41626-504c-4092-aca9-41f936934328' { return 'EFI_CERT_SHA256' } + '3bd2a492-96c0-4079-b420-fcf98ef103ed' { return 'EFI_CERT_X509_SHA256' } + '826ca512-cf10-4ac9-b187-be01496631bd' { return 'EFI_CERT_SHA1' } + '67f8444f-8743-48f1-a328-1eaab8736080' { return 'EFI_CERT_SHA224' } + 'ff3e5307-9fd0-48c9-85f1-8ad56c701e01' { return 'EFI_CERT_SHA384' } + '093e0fae-a6c4-4f50-9f1b-d41e2b89c19a' { return 'EFI_CERT_SHA512' } + default { return 'UNKNOWN' } + } +} + +function Get-EfiSignatureDatabaseEntries { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [byte[]]$Bytes, + [Parameter(Mandatory = $true)] + [string]$VariableName + ) + + # Parse EFI signature database bytes into typed entries. + $parsedEntries = [System.Collections.Generic.List[object]]::new() + + if ($null -eq $Bytes -or $Bytes.Length -lt 28) { + return @() + } + + $offset = 0 + $listIndex = 0 + + while (($offset + 28) -le $Bytes.Length) { + $listIndex++ + + $signatureTypeGuid = Get-EfiGuidString -Bytes $Bytes -Offset $offset + $signatureListSize = [BitConverter]::ToUInt32($Bytes, $offset + 16) + $signatureHeaderSize = [BitConverter]::ToUInt32($Bytes, $offset + 20) + $signatureSize = [BitConverter]::ToUInt32($Bytes, $offset + 24) + + if ([string]::IsNullOrWhiteSpace($signatureTypeGuid) -or $signatureListSize -lt 28 -or $signatureSize -lt 16 -or ($offset + $signatureListSize) -gt $Bytes.Length) { + break + } + + $signatureTypeName = Get-EfiSignatureTypeName -SignatureTypeGuid $signatureTypeGuid + $entryStartOffset = $offset + 28 + $signatureHeaderSize + $usableBytes = $signatureListSize - 28 - $signatureHeaderSize + if ($usableBytes -lt 0) { + break + } + + $entryCount = if ($signatureSize -gt 0) { [int][math]::Floor($usableBytes / $signatureSize) } else { 0 } + + for ($entryIndex = 0; $entryIndex -lt $entryCount; $entryIndex++) { + $currentOffset = $entryStartOffset + ($entryIndex * $signatureSize) + if (($currentOffset + $signatureSize) -gt ($offset + $signatureListSize)) { + break + } + + $signatureOwnerGuid = Get-EfiGuidString -Bytes $Bytes -Offset $currentOffset + $signatureDataLength = [int]$signatureSize - 16 + if ($signatureDataLength -lt 0) { + continue + } + + $signatureData = [byte[]]::new($signatureDataLength) + [Array]::Copy($Bytes, $currentOffset + 16, $signatureData, 0, $signatureDataLength) + + $entry = [ordered]@{ + VariableName = $VariableName + SignatureListIndex = $listIndex + SignatureEntryIndex = $entryIndex + 1 + SignatureTypeGuid = $signatureTypeGuid + SignatureTypeName = $signatureTypeName + SignatureOwnerGuid = $signatureOwnerGuid + SignatureDataLength = $signatureDataLength + HashHex = $null + CertificateSubject = $null + CertificateIssuer = $null + CertificateNotBefore = $null + CertificateNotAfter = $null + CertificateThumbprint = $null + CertificateSha256 = $null + DataSha256 = $null + EntrySummary = $null + } + + switch ($signatureTypeName) { + 'EFI_CERT_X509' { + try { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($signatureData) + $entry.CertificateSubject = $certificate.Subject + $entry.CertificateIssuer = $certificate.Issuer + $entry.CertificateNotBefore = $certificate.NotBefore.ToString('yyyy-MM-dd HH:mm:ss') + $entry.CertificateNotAfter = $certificate.NotAfter.ToString('yyyy-MM-dd HH:mm:ss') + $entry.CertificateThumbprint = $certificate.Thumbprint + $entry.CertificateSha256 = Get-CertificateSha256 -Certificate $certificate + $entry.EntrySummary = "CertificateSubject=$($entry.CertificateSubject)" + } + catch { + $entry.DataSha256 = Get-ByteArraySha256 -Bytes $signatureData + $entry.EntrySummary = "CertificateParseError=$($_.Exception.Message)" + } + } + 'EFI_CERT_SHA256' { + $entry.HashHex = Get-ByteArrayHexString -Bytes $signatureData + $entry.EntrySummary = "ImageHash=$($entry.HashHex)" + } + 'EFI_CERT_X509_SHA256' { + $entry.HashHex = Get-ByteArrayHexString -Bytes $signatureData + $entry.EntrySummary = "CertificateHash=$($entry.HashHex)" + } + default { + $entry.DataSha256 = Get-ByteArraySha256 -Bytes $signatureData + $entry.EntrySummary = "DataSha256=$($entry.DataSha256)" + } + } + + $parsedEntries.Add([PSCustomObject]$entry) | Out-Null + } + + if ($signatureListSize -le 0) { + break + } + + $offset += [int]$signatureListSize + } + + return @($parsedEntries) +} + +function Write-EfiSignatureDatabaseReport { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$VariableName, + [Parameter(Mandatory = $true)] + [object[]]$Entries, + [Parameter(Mandatory = $true)] + [string]$ReportPath + ) + + # Write a readable parsed report and a smaller summary for EFI signature database entries. + $summaryPath = Join-Path -Path (Split-Path -Path $ReportPath -Parent) -ChildPath "${VariableName}_summary.txt" + $typeGroups = @($Entries | Group-Object SignatureTypeName | Sort-Object Name) + $ownerGroups = @($Entries | Where-Object { -not [string]::IsNullOrWhiteSpace($_.SignatureOwnerGuid) } | Group-Object SignatureOwnerGuid | Sort-Object Name) + $hashEntries = @($Entries | Where-Object { -not [string]::IsNullOrWhiteSpace($_.HashHex) }) + $uniqueHashCount = @($hashEntries | Select-Object -ExpandProperty HashHex -Unique).Count + $duplicateHashGroups = @( + $hashEntries | + Group-Object HashHex | + Where-Object { $_.Count -gt 1 } | + Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, @{ Expression = 'Name'; Descending = $false } + ) + $duplicateHashValueCount = $duplicateHashGroups.Count + $duplicateHashEntryCount = if ($duplicateHashGroups.Count -gt 0) { ($duplicateHashGroups | Measure-Object -Property Count -Sum).Sum } else { 0 } + + $reportLines = @( + "VariableName: $VariableName" + "ParsedEntryCount: $($Entries.Count)" + "UniqueHashCount: $uniqueHashCount" + "DuplicateHashValueCount: $duplicateHashValueCount" + "DuplicateHashEntryCount: $duplicateHashEntryCount" + "OwnerGuidCount: $($ownerGroups.Count)" + ) + + $summaryLines = @( + "VariableName: $VariableName" + "ParsedEntryCount: $($Entries.Count)" + "UniqueHashCount: $uniqueHashCount" + "DuplicateHashValueCount: $duplicateHashValueCount" + "DuplicateHashEntryCount: $duplicateHashEntryCount" + "OwnerGuidCount: $($ownerGroups.Count)" + ) + + if ($Entries.Count -gt 0) { + $reportLines += '' + $reportLines += 'ParsedTypeCounts:' + $summaryLines += '' + $summaryLines += 'ParsedTypeCounts:' + foreach ($typeGroup in $typeGroups) { + $reportLines += "$($typeGroup.Name): $($typeGroup.Count)" + $summaryLines += "$($typeGroup.Name): $($typeGroup.Count)" + } + + $reportLines += '' + $reportLines += 'OwnerGuidCounts:' + $summaryLines += '' + $summaryLines += 'OwnerGuidCounts:' + if ($ownerGroups.Count -gt 0) { + foreach ($ownerGroup in $ownerGroups) { + $reportLines += "$($ownerGroup.Name): $($ownerGroup.Count)" + $summaryLines += "$($ownerGroup.Name): $($ownerGroup.Count)" + } + } + else { + $reportLines += '' + $summaryLines += '' + } + + $reportLines += '' + $reportLines += 'DuplicateHashes:' + $summaryLines += '' + $summaryLines += 'DuplicateHashes:' + if ($duplicateHashGroups.Count -gt 0) { + foreach ($duplicateHashGroup in $duplicateHashGroups) { + $reportLines += "$($duplicateHashGroup.Name): $($duplicateHashGroup.Count)" + $summaryLines += "$($duplicateHashGroup.Name): $($duplicateHashGroup.Count)" + } + } + else { + $reportLines += '' + $summaryLines += '' + } + + $reportLines += '' + $reportLines += 'Entries:' + foreach ($entry in $Entries) { + $reportLines += "[List $($entry.SignatureListIndex), Entry $($entry.SignatureEntryIndex)] Type=$($entry.SignatureTypeName) ($($entry.SignatureTypeGuid))" + $reportLines += "SignatureOwnerGuid: $($entry.SignatureOwnerGuid)" + $reportLines += "SignatureDataLength: $($entry.SignatureDataLength)" + + if ($entry.CertificateSubject) { + $reportLines += "CertificateSubject: $($entry.CertificateSubject)" + $reportLines += "CertificateIssuer: $($entry.CertificateIssuer)" + $reportLines += "CertificateNotBefore: $($entry.CertificateNotBefore)" + $reportLines += "CertificateNotAfter: $($entry.CertificateNotAfter)" + $reportLines += "CertificateThumbprint: $($entry.CertificateThumbprint)" + $reportLines += "CertificateSha256: $($entry.CertificateSha256)" + } + elseif ($entry.HashHex) { + $reportLines += "HashHex: $($entry.HashHex)" + } + else { + $reportLines += "DataSha256: $($entry.DataSha256)" + } + + if ($entry.EntrySummary) { + $reportLines += "EntrySummary: $($entry.EntrySummary)" + } + + $reportLines += '' + } + } + else { + $reportLines += '' + $reportLines += 'ParsedTypeCounts: ' + $reportLines += '' + $reportLines += 'OwnerGuidCounts: ' + $reportLines += '' + $reportLines += 'DuplicateHashes: ' + $reportLines += '' + $reportLines += 'Entries: ' + + $summaryLines += '' + $summaryLines += 'ParsedTypeCounts: ' + $summaryLines += '' + $summaryLines += 'OwnerGuidCounts: ' + $summaryLines += '' + $summaryLines += 'DuplicateHashes: ' + } + + Write-DiagnosticsTextFile -FilePath $ReportPath -Content $reportLines + Write-DiagnosticsTextFile -FilePath $summaryPath -Content $summaryLines +} + +function Save-SecureBootVariableDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$FirmwarePath, + [Parameter(Mandatory = $true)] + [string]$VariableName + ) + + # Export raw Secure Boot variable data and parse EFI signature database entries when available. + $textPath = Join-Path -Path $FirmwarePath -ChildPath "$VariableName.txt" + $parsedReportPath = Join-Path -Path $FirmwarePath -ChildPath "${VariableName}_parsed.txt" + + if ($null -eq (Get-Command -Name Get-SecureBootUEFI -ErrorAction SilentlyContinue)) { + Write-DiagnosticsTextFile -FilePath $textPath -Content 'Get-SecureBootUEFI is not available.' + Write-DiagnosticsTextFile -FilePath $parsedReportPath -Content 'Get-SecureBootUEFI is not available.' + WriteLog "Secure Boot variable [$StageName] $VariableName unavailable: Get-SecureBootUEFI not available." + return $null + } + + try { + try { + $variable = Get-SecureBootUEFI -Name $VariableName -ErrorAction Stop + } + catch [System.Management.Automation.ParameterBindingException] { + $variable = Get-SecureBootUEFI $VariableName -ErrorAction Stop + } + + $bytes = [byte[]]@() + + if ($variable -and $variable.PSObject.Properties['Bytes']) { + $bytes = [byte[]]$variable.Bytes + } + elseif ($variable -and $variable.PSObject.Properties['Content']) { + $bytes = [byte[]]$variable.Content + } + + $binPath = Join-Path -Path $FirmwarePath -ChildPath "$VariableName.bin" + [System.IO.File]::WriteAllBytes($binPath, $bytes) + + $sha256 = Get-ByteArraySha256 -Bytes $bytes + $marker = Get-ByteArrayAsciiMarker -Bytes $bytes + $markerText = if ($marker) { $marker } else { '' } + $parsedEntries = @(Get-EfiSignatureDatabaseEntries -Bytes $bytes -VariableName $VariableName) + $typeSummaryText = if ($parsedEntries.Count -gt 0) { + (($parsedEntries | Group-Object SignatureTypeName | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Count)" }) -join '; ') + } + else { + '' + } + + $hashEntries = @($parsedEntries | Where-Object { -not [string]::IsNullOrWhiteSpace($_.HashHex) }) + $uniqueHashCount = @($hashEntries | Select-Object -ExpandProperty HashHex -Unique).Count + $duplicateHashValueCount = @($hashEntries | Group-Object HashHex | Where-Object { $_.Count -gt 1 }).Count + $ownerGuidCount = @($parsedEntries | Where-Object { -not [string]::IsNullOrWhiteSpace($_.SignatureOwnerGuid) } | Group-Object SignatureOwnerGuid).Count + + $summaryLines = @( + "VariableName: $VariableName" + "ByteCount: $($bytes.Length)" + "SHA256: $sha256" + "AsciiMarker: $markerText" + "ParsedEntryCount: $($parsedEntries.Count)" + "ParsedTypeCounts: $typeSummaryText" + "UniqueHashCount: $uniqueHashCount" + "DuplicateHashValueCount: $duplicateHashValueCount" + "OwnerGuidCount: $ownerGuidCount" + '' + 'Details:' + ($variable | Format-List * | Out-String).TrimEnd() + ) + + Write-DiagnosticsTextFile -FilePath $textPath -Content $summaryLines + Write-EfiSignatureDatabaseReport -VariableName $VariableName -Entries $parsedEntries -ReportPath $parsedReportPath + WriteLog "Secure Boot variable [$StageName] $($VariableName): Bytes=$($bytes.Length); SHA256=$sha256; Marker=$markerText; ParsedEntries=$($parsedEntries.Count); ParsedTypes=$typeSummaryText; UniqueHashes=$uniqueHashCount; DuplicateHashValues=$duplicateHashValueCount; OwnerGuidCount=$ownerGuidCount." + + return [PSCustomObject]@{ + VariableName = $VariableName + Bytes = $bytes + Sha256 = $sha256 + Marker = $markerText + ParsedEntries = $parsedEntries + } + } + catch { + Write-DiagnosticsTextFile -FilePath $textPath -Content "Error: $($_.Exception.Message)" + Write-DiagnosticsTextFile -FilePath $parsedReportPath -Content "Error: $($_.Exception.Message)" + WriteLog "Secure Boot variable [$StageName] $($VariableName) unavailable: $($_.Exception.Message)" + return $null + } +} + +function Save-FirmwareSecureBootDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$StagePath + ) + + # Collect firmware and Secure Boot state without affecting deployment flow. + $firmwarePath = Join-Path -Path $StagePath -ChildPath 'Firmware' + try { + New-Item -Path $firmwarePath -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + WriteLog "Warning: Failed to create firmware diagnostics folder for stage $StageName. $($_.Exception.Message)" + return $null + } + + $summaryLines = @( + "Stage: $StageName" + "Timestamp: $(Get-Date -Format 's')" + ) + + try { + $controlValues = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control' -Name PEFirmwareType -ErrorAction Stop + $peFirmwareType = $controlValues.PEFirmwareType + $peFirmwareTypeText = switch ($peFirmwareType) { + 1 { 'BIOS' } + 2 { 'UEFI' } + default { 'Unknown' } + } + + $summaryLines += "PEFirmwareType: $peFirmwareType ($peFirmwareTypeText)" + WriteLog "Firmware state [$StageName]: PEFirmwareType=$peFirmwareType ($peFirmwareTypeText)." + } + catch { + $summaryLines += 'PEFirmwareType: ' + WriteLog "Firmware state [$StageName]: PEFirmwareType unavailable. $($_.Exception.Message)" + } + + if ($null -ne (Get-Command -Name Confirm-SecureBootUEFI -ErrorAction SilentlyContinue)) { + try { + $confirmResult = Confirm-SecureBootUEFI -ErrorAction Stop + $summaryLines += "Confirm-SecureBootUEFI: $confirmResult" + WriteLog "Firmware state [$StageName]: Confirm-SecureBootUEFI=$confirmResult." + } + catch { + $summaryLines += "Confirm-SecureBootUEFI: $($_.Exception.Message)" + WriteLog "Firmware state [$StageName]: Confirm-SecureBootUEFI failed. $($_.Exception.Message)" + } + } + else { + $summaryLines += 'Confirm-SecureBootUEFI: ' + WriteLog "Firmware state [$StageName]: Confirm-SecureBootUEFI not available." + } + + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $firmwarePath -ChildPath 'firmware-summary.txt') -Content $summaryLines + + $variableEvidence = [ordered]@{} + foreach ($variableName in @('PK', 'KEK', 'db', 'dbx')) { + $currentVariableEvidence = Save-SecureBootVariableDiagnostics -StageName $StageName -FirmwarePath $firmwarePath -VariableName $variableName + if ($null -ne $currentVariableEvidence) { + $variableEvidence[$variableName] = $currentVariableEvidence + } + } + + return [PSCustomObject]$variableEvidence +} + +function Get-CertificateChainEvidence { + [CmdletBinding()] + param( + [Parameter()] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + # Build a best-effort certificate chain and look for 2011 or 2023 markers. + $result = [PSCustomObject]@{ + Marker = '' + BuildSucceeded = $false + ChainStatusText = '' + ChainElementText = @() + ChainCertificateHashes = @() + } + + if ($null -eq $Certificate) { + return $result + } + + $markerCandidates = @( + 'Windows UEFI CA 2023', + 'Windows UEFI CA 2011', + 'Microsoft Corporation UEFI CA 2011', + 'Microsoft Corporation KEK CA 2011', + 'Microsoft Windows Production PCA 2011', + 'Microsoft Windows' + ) + + $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() + try { + # Avoid revocation/network dependencies in WinPE while still building the local chain. + $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::IgnoreEndRevocationUnknown + $result.BuildSucceeded = $chain.Build($Certificate) + + $chainStatusLines = @() + foreach ($chainStatus in $chain.ChainStatus) { + $statusInformation = $chainStatus.StatusInformation + if (-not [string]::IsNullOrWhiteSpace($statusInformation)) { + $chainStatusLines += "$($chainStatus.Status): $($statusInformation.Trim())" + } + } + + if ($chainStatusLines.Count -gt 0) { + $result.ChainStatusText = $chainStatusLines -join ' | ' + } + + $elementLines = @() + $chainCertificateHashes = @() + $index = 0 + foreach ($chainElement in $chain.ChainElements) { + $index++ + $chainCertificate = $chainElement.Certificate + $certificateSha256 = Get-CertificateSha256 -Certificate $chainCertificate + if ($certificateSha256) { + $chainCertificateHashes += $certificateSha256 + } + + $elementLines += "[{0}] Subject={1}; Issuer={2}; Thumbprint={3}; NotBefore={4}; NotAfter={5}; CertificateSha256={6}" -f ` + $index, ` + $chainCertificate.Subject, ` + $chainCertificate.Issuer, ` + $chainCertificate.Thumbprint, ` + $chainCertificate.NotBefore.ToString('yyyy-MM-dd HH:mm:ss'), ` + $chainCertificate.NotAfter.ToString('yyyy-MM-dd HH:mm:ss'), ` + $certificateSha256 + + if ($result.Marker -eq '') { + foreach ($markerCandidate in $markerCandidates) { + if ($chainCertificate.Subject -match [regex]::Escape($markerCandidate) -or $chainCertificate.Issuer -match [regex]::Escape($markerCandidate)) { + $result.Marker = $markerCandidate + break + } + } + } + } + + $result.ChainElementText = $elementLines + $result.ChainCertificateHashes = @($chainCertificateHashes) + } + catch { + $result.ChainStatusText = "Error: $($_.Exception.Message)" + } + finally { + $chain.Dispose() + } + + return $result +} + +function Save-BootFileArtifact { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$BootFilesPath, + [Parameter(Mandatory = $true)] + [pscustomobject]$BootEntry + ) + + # Capture metadata and a copy of each requested boot-chain file when present. + $metadataRoot = Join-Path -Path $BootFilesPath -ChildPath 'Metadata' + $safeFileName = ($BootEntry.CopyRelativePath -replace '[\\/:*?"<>| ]', '_') + '.txt' + $metadataPath = Join-Path -Path $metadataRoot -ChildPath $safeFileName + + $summaryLines = @( + "Label: $($BootEntry.Label)" + "Path: $($BootEntry.Path)" + ) + + if ([string]::IsNullOrWhiteSpace($BootEntry.Path) -or -not (Test-Path -Path $BootEntry.Path -PathType Leaf)) { + $summaryLines += 'Exists: False' + Write-DiagnosticsTextFile -FilePath $metadataPath -Content $summaryLines + WriteLog "Boot file [$StageName] $($BootEntry.CopyRelativePath): Missing." + + return [PSCustomObject]@{ + Label = $BootEntry.Label + CopyRelativePath = $BootEntry.CopyRelativePath + Exists = $false + FileHash = $null + SignatureStatus = 'Missing' + CertificateMarker = '' + ChainCertificateHashes = @() + } + } + + try { + $fileItem = Get-Item -Path $BootEntry.Path -ErrorAction Stop + $summaryLines += 'Exists: True' + $summaryLines += "Size: $($fileItem.Length)" + + $hashValue = $null + try { + $hashValue = (Get-FileHash -Path $BootEntry.Path -Algorithm SHA256 -ErrorAction Stop).Hash + } + catch { + $hashValue = $null + } + + $summaryLines += "SHA256: $(if ($hashValue) { $hashValue } else { '' })" + + $fileVersion = $null + $productVersion = $null + if ($fileItem.VersionInfo) { + $fileVersion = $fileItem.VersionInfo.FileVersion + $productVersion = $fileItem.VersionInfo.ProductVersion + } + + $summaryLines += "FileVersion: $(if ($fileVersion) { $fileVersion } else { '' })" + $summaryLines += "ProductVersion: $(if ($productVersion) { $productVersion } else { '' })" + + $skipSignatureCheck = if ($BootEntry.PSObject.Properties['SkipSignatureCheck']) { [bool]$BootEntry.SkipSignatureCheck } else { $false } + + $signatureStatus = if ($skipSignatureCheck) { '' } else { '' } + $signerSubject = '' + $signerIssuer = '' + $signerThumbprint = '' + $signerNotBefore = '' + $signerNotAfter = '' + $certificateMarker = '' + $chainBuildSucceeded = $false + $chainStatusText = '' + $chainElementText = @() + $chainCertificateHashes = @() + + if (-not $skipSignatureCheck -and $null -ne (Get-Command -Name Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + try { + $signature = Get-AuthenticodeSignature -FilePath $BootEntry.Path -ErrorAction Stop + $signatureStatus = [string]$signature.Status + + if ($signature.SignerCertificate) { + $signerSubject = $signature.SignerCertificate.Subject + $signerIssuer = $signature.SignerCertificate.Issuer + $signerThumbprint = $signature.SignerCertificate.Thumbprint + $signerNotBefore = $signature.SignerCertificate.NotBefore.ToString('yyyy-MM-dd HH:mm:ss') + $signerNotAfter = $signature.SignerCertificate.NotAfter.ToString('yyyy-MM-dd HH:mm:ss') + + $chainEvidence = Get-CertificateChainEvidence -Certificate $signature.SignerCertificate + $certificateMarker = $chainEvidence.Marker + $chainBuildSucceeded = $chainEvidence.BuildSucceeded + $chainStatusText = $chainEvidence.ChainStatusText + $chainElementText = $chainEvidence.ChainElementText + $chainCertificateHashes = @($chainEvidence.ChainCertificateHashes | ForEach-Object { $_.ToUpperInvariant() }) + } + } + catch { + $signatureStatus = " $($_.Exception.Message)" + } + } + + $summaryLines += "AuthenticodeStatus: $signatureStatus" + $summaryLines += "SignerSubject: $signerSubject" + $summaryLines += "SignerIssuer: $signerIssuer" + $summaryLines += "SignerThumbprint: $signerThumbprint" + $summaryLines += "SignerNotBefore: $signerNotBefore" + $summaryLines += "SignerNotAfter: $signerNotAfter" + $summaryLines += "CertificateMarker: $certificateMarker" + $summaryLines += "ChainBuildSucceeded: $chainBuildSucceeded" + $summaryLines += "ChainStatus: $chainStatusText" + + if ($chainElementText.Count -gt 0) { + $summaryLines += '' + $summaryLines += 'CertificateChain:' + $summaryLines += $chainElementText + } + + $copyPath = Join-Path -Path (Join-Path -Path $BootFilesPath -ChildPath 'Files') -ChildPath $BootEntry.CopyRelativePath + try { + New-Item -Path (Split-Path -Path $copyPath -Parent) -ItemType Directory -Force -ErrorAction Stop | Out-Null + Copy-Item -Path $BootEntry.Path -Destination $copyPath -Force -ErrorAction Stop + $summaryLines += "CopiedTo: $copyPath" + } + catch { + $summaryLines += 'CopiedTo: ' + $summaryLines += "CopyError: $($_.Exception.Message)" + } + + Write-DiagnosticsTextFile -FilePath $metadataPath -Content $summaryLines + + $logHash = if ($hashValue) { $hashValue } else { '' } + WriteLog "Boot file [$StageName] $($BootEntry.CopyRelativePath): Size=$($fileItem.Length); SHA256=$logHash; Signature=$signatureStatus; Marker=$certificateMarker; Signer=$signerSubject." + + return [PSCustomObject]@{ + Label = $BootEntry.Label + CopyRelativePath = $BootEntry.CopyRelativePath + Exists = $true + FileHash = if ($hashValue) { $hashValue.ToUpperInvariant() } else { $null } + SignatureStatus = $signatureStatus + CertificateMarker = $certificateMarker + ChainCertificateHashes = @($chainCertificateHashes) + } + } + catch { + $summaryLines += "Error: $($_.Exception.Message)" + Write-DiagnosticsTextFile -FilePath $metadataPath -Content $summaryLines + WriteLog "Warning: Failed to inspect boot file [$StageName] $($BootEntry.CopyRelativePath). $($_.Exception.Message)" + + return [PSCustomObject]@{ + Label = $BootEntry.Label + CopyRelativePath = $BootEntry.CopyRelativePath + Exists = $false + FileHash = $null + SignatureStatus = " $($_.Exception.Message)" + CertificateMarker = '' + ChainCertificateHashes = @() + } + } +} + +function Save-BootFileDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$StagePath, + [Parameter(Mandatory = $true)] + [int]$DiskNumber, + [Parameter()] + [string]$WindowsDrivePath = 'W:\' + ) + + # Inspect EFI and OS boot files using a temporary ESP access path when needed. + $bootFilesPath = Join-Path -Path $StagePath -ChildPath 'BootFiles' + try { + New-Item -Path $bootFilesPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + WriteLog "Warning: Failed to create boot-file diagnostics folder for stage $StageName. $($_.Exception.Message)" + return @() + } + + $bootEvidence = @() + $espAccess = Open-EspPartitionAccess -DiskNumber $DiskNumber + try { + $espRoot = if ($null -ne $espAccess) { $espAccess.DrivePath } else { $null } + + $bootEntries = @( + [PSCustomObject]@{ + Label = 'ESP Microsoft Boot Manager' + Path = if ($espRoot) { Join-Path -Path $espRoot -ChildPath 'EFI\Microsoft\Boot\bootmgfw.efi' } else { $null } + CopyRelativePath = 'ESP\EFI\Microsoft\Boot\bootmgfw.efi' + }, + [PSCustomObject]@{ + Label = 'ESP fallback bootx64' + Path = if ($espRoot) { Join-Path -Path $espRoot -ChildPath 'EFI\Boot\bootx64.efi' } else { $null } + CopyRelativePath = 'ESP\EFI\Boot\bootx64.efi' + }, + [PSCustomObject]@{ + Label = 'ESP BCD store' + Path = if ($espRoot) { Join-Path -Path $espRoot -ChildPath 'EFI\Microsoft\Boot\BCD' } else { $null } + CopyRelativePath = 'ESP\EFI\Microsoft\Boot\BCD' + SkipSignatureCheck = $true + } + ) + + if (-not [string]::IsNullOrWhiteSpace($WindowsDrivePath) -and (Test-Path -Path $WindowsDrivePath)) { + $bootEntries += [PSCustomObject]@{ + Label = 'Offline Windows winload' + Path = Join-Path -Path $WindowsDrivePath -ChildPath 'Windows\System32\winload.efi' + CopyRelativePath = 'Windows\Windows\System32\winload.efi' + } + } + else { + WriteLog "Boot file [$StageName]: Skipping offline Windows loader inspection because $WindowsDrivePath is not accessible." + } + + foreach ($bootEntry in $bootEntries) { + $bootEvidence += Save-BootFileArtifact -StageName $StageName -BootFilesPath $bootFilesPath -BootEntry $bootEntry + } + } + finally { + Close-EspPartitionAccess -EspAccess $espAccess + } + + return @($bootEvidence) +} + +function Invoke-DiagnosticsCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter()] + [string]$ArgumentList, + [Parameter(Mandatory = $true)] + [string]$OutputFilePath, + [Parameter(Mandatory = $true)] + [string]$LogLabel + ) + + # Run a diagnostics command, save the full output, and mirror it to ScriptLog. + $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" + $stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)" + + try { + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -RedirectStandardOutput $stdOutTempFile -RedirectStandardError $stdErrTempFile -Wait -PassThru -NoNewWindow -ErrorAction Stop + + $stdOutContent = if (Test-Path -Path $stdOutTempFile) { Get-Content -Path $stdOutTempFile -Raw } else { '' } + $stdErrContent = if (Test-Path -Path $stdErrTempFile) { Get-Content -Path $stdErrTempFile -Raw } else { '' } + + $outputParts = @() + if (-not [string]::IsNullOrWhiteSpace($stdOutContent)) { + $outputParts += $stdOutContent.TrimEnd() + } + if (-not [string]::IsNullOrWhiteSpace($stdErrContent)) { + $outputParts += "STDERR:`r`n$($stdErrContent.TrimEnd())" + } + if ($outputParts.Count -eq 0) { + $outputParts += '' + } + + $combinedOutput = $outputParts -join "`r`n`r`n" + Write-DiagnosticsTextFile -FilePath $OutputFilePath -Content $combinedOutput + + WriteLog "$LogLabel exit code: $($process.ExitCode)" + foreach ($outputLine in ($combinedOutput -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($outputLine)) { + WriteLog $outputLine + } + } + + return $process.ExitCode + } + catch { + $errorMessage = $_.Exception.Message + Write-DiagnosticsTextFile -FilePath $OutputFilePath -Content @( + "Command: $FilePath $ArgumentList" + "Error: $errorMessage" + ) + WriteLog "Warning: $LogLabel failed. $errorMessage" + return $null + } + finally { + Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-SecureBootDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter()] + [string]$DiagnosticsRoot, + [Parameter(Mandatory = $true)] + [int]$DiskNumber, + [Parameter()] + [string]$WindowsDrivePath = 'W:\', + [Parameter()] + [bool]$IncludeBootFiles = $true + ) + + # Collect firmware and storage telemetry, and optionally collect boot-file telemetry. + if ([string]::IsNullOrWhiteSpace($DiagnosticsRoot)) { + WriteLog "Secure Boot diagnostics [$StageName] skipped: diagnostics folder unavailable." + return $null + } + + $stagePath = New-DiagnosticsStageFolder -DiagnosticsRoot $DiagnosticsRoot -StageName $StageName + if ([string]::IsNullOrWhiteSpace($stagePath)) { + WriteLog "Secure Boot diagnostics [$StageName] skipped: stage folder unavailable." + return $null + } + + $firmwareEvidence = Save-FirmwareSecureBootDiagnostics -StageName $StageName -StagePath $stagePath + Save-StorageSnapshot -StageName $StageName -StagePath $stagePath -DiskNumber $DiskNumber + + $bootFileEvidence = @() + if ($IncludeBootFiles) { + $bootFileEvidence = @(Save-BootFileDiagnostics -StageName $StageName -StagePath $stagePath -DiskNumber $DiskNumber -WindowsDrivePath $WindowsDrivePath) + } + else { + WriteLog "Secure Boot diagnostics [$StageName]: Boot-file inspection skipped." + } + + return [PSCustomObject]@{ + StageName = $StageName + StagePath = $stagePath + FirmwareEvidence = $firmwareEvidence + BootFileEvidence = @($bootFileEvidence) + } +} + +function Get-BcdSettingValue { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$BcdOutputPath, + [Parameter(Mandatory = $true)] + [string]$SettingName + ) + + # Extract a single setting value from a saved bcdedit output file. + if (-not (Test-Path -Path $BcdOutputPath -PathType Leaf)) { + return $null + } + + try { + $pattern = '^\s*' + [regex]::Escape($SettingName) + '\s+(.+)$' + foreach ($line in Get-Content -Path $BcdOutputPath -ErrorAction Stop) { + $match = [regex]::Match($line, $pattern) + if ($match.Success) { + return $match.Groups[1].Value.Trim() + } + } + } + catch { + WriteLog "Warning: Failed to parse BCD setting '$SettingName' from $BcdOutputPath. $($_.Exception.Message)" + } + + return $null +} + +function Write-BcdSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter(Mandatory = $true)] + [string]$BcdPath + ) + + # Surface the highest value BCD fields directly in ScriptLog and a summary file. + $bootMgrPath = Join-Path -Path $BcdPath -ChildPath 'bcdedit_bootmgr_v.txt' + $defaultPath = Join-Path -Path $BcdPath -ChildPath 'bcdedit_default_v.txt' + + $bootMgrDevice = Get-BcdSettingValue -BcdOutputPath $bootMgrPath -SettingName 'device' + $bootMgrLoaderPath = Get-BcdSettingValue -BcdOutputPath $bootMgrPath -SettingName 'path' + $defaultDevice = Get-BcdSettingValue -BcdOutputPath $defaultPath -SettingName 'device' + $defaultOsDevice = Get-BcdSettingValue -BcdOutputPath $defaultPath -SettingName 'osdevice' + $defaultLoaderPath = Get-BcdSettingValue -BcdOutputPath $defaultPath -SettingName 'path' + + $summaryObject = [PSCustomObject]@{ + StageName = $StageName + BootMgrDevice = $bootMgrDevice + BootMgrPath = $bootMgrLoaderPath + DefaultDevice = $defaultDevice + DefaultOsDevice = $defaultOsDevice + DefaultPath = $defaultLoaderPath + } + + $summaryLines = @( + "Stage: $StageName" + "BootMgrDevice: $(if ($bootMgrDevice) { $bootMgrDevice } else { '' })" + "BootMgrPath: $(if ($bootMgrLoaderPath) { $bootMgrLoaderPath } else { '' })" + "DefaultDevice: $(if ($defaultDevice) { $defaultDevice } else { '' })" + "DefaultOsDevice: $(if ($defaultOsDevice) { $defaultOsDevice } else { '' })" + "DefaultPath: $(if ($defaultLoaderPath) { $defaultLoaderPath } else { '' })" + ) + + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $BcdPath -ChildPath 'bcd_summary.txt') -Content $summaryLines + WriteLog "BCD [$StageName] Summary: {bootmgr}.device=$(if ($bootMgrDevice) { $bootMgrDevice } else { '' }); {bootmgr}.path=$(if ($bootMgrLoaderPath) { $bootMgrLoaderPath } else { '' }); {default}.device=$(if ($defaultDevice) { $defaultDevice } else { '' }); {default}.osdevice=$(if ($defaultOsDevice) { $defaultOsDevice } else { '' }); {default}.path=$(if ($defaultLoaderPath) { $defaultLoaderPath } else { '' })." + + return $summaryObject +} + +function Write-BootExpectationSummary { + [CmdletBinding()] + param( + [Parameter()] + [pscustomobject]$PostApplyDiagnostics, + [Parameter()] + [pscustomobject]$BcdDiagnostics + ) + + # Decide whether the device should boot based on parsed dbx, boot files, and BCD evidence. + if ($null -eq $PostApplyDiagnostics -or $null -eq $BcdDiagnostics) { + WriteLog 'Boot expectation: Unable to evaluate because required diagnostics were not available.' + return + } + + $bootEvidence = @($PostApplyDiagnostics.BootFileEvidence) + $bootMgrEvidence = @($bootEvidence | Where-Object { $_.CopyRelativePath -eq 'ESP\EFI\Microsoft\Boot\bootmgfw.efi' }) | Select-Object -First 1 + $fallbackEvidence = @($bootEvidence | Where-Object { $_.CopyRelativePath -eq 'ESP\EFI\Boot\bootx64.efi' }) | Select-Object -First 1 + $winloadEvidence = @($bootEvidence | Where-Object { $_.CopyRelativePath -eq 'Windows\Windows\System32\winload.efi' }) | Select-Object -First 1 + + $dbxVariableEvidence = if ($PostApplyDiagnostics.FirmwareEvidence -and $PostApplyDiagnostics.FirmwareEvidence.PSObject.Properties['dbx']) { + $PostApplyDiagnostics.FirmwareEvidence.dbx + } + else { + $null + } + + $dbxEntries = if ($dbxVariableEvidence -and $dbxVariableEvidence.PSObject.Properties['ParsedEntries']) { + @($dbxVariableEvidence.ParsedEntries) + } + else { + @() + } + + $dbxImageHashEntries = @($dbxEntries | Where-Object { + $_.SignatureTypeName -eq 'EFI_CERT_SHA256' -and -not [string]::IsNullOrWhiteSpace($_.HashHex) + }) + + $dbxCertificateHashEntries = @($dbxEntries | Where-Object { + ($_.SignatureTypeName -eq 'EFI_CERT_X509_SHA256' -and -not [string]::IsNullOrWhiteSpace($_.HashHex)) -or + ($_.SignatureTypeName -eq 'EFI_CERT_X509' -and -not [string]::IsNullOrWhiteSpace($_.CertificateSha256)) + }) + + $dbxImageHashes = @($dbxImageHashEntries | ForEach-Object { $_.HashHex.ToUpperInvariant() } | Select-Object -Unique) + $dbxCertificateHashes = @($dbxCertificateHashEntries | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_.HashHex)) { + $_.HashHex.ToUpperInvariant() + } + elseif (-not [string]::IsNullOrWhiteSpace($_.CertificateSha256)) { + $_.CertificateSha256.ToUpperInvariant() + } + } | Select-Object -Unique) + + $bootExpectation = 'ExpectedToBootBasedOnCollectedEvidence' + $reasonLines = [System.Collections.Generic.List[string]]::new() + $warningLines = [System.Collections.Generic.List[string]]::new() + $bootComparisonLines = [System.Collections.Generic.List[string]]::new() + + if ($null -eq $bootMgrEvidence -or -not $bootMgrEvidence.Exists) { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add('ESP bootmgfw.efi is missing.') | Out-Null + } + elseif ($bootMgrEvidence.SignatureStatus -ne 'Valid') { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add("ESP bootmgfw.efi signature status is $($bootMgrEvidence.SignatureStatus).") | Out-Null + } + + if ($null -eq $winloadEvidence -or -not $winloadEvidence.Exists) { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add('Windows winload.efi is missing.') | Out-Null + } + elseif ($winloadEvidence.SignatureStatus -ne 'Valid') { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add("Windows winload.efi signature status is $($winloadEvidence.SignatureStatus).") | Out-Null + } + + if ($null -ne $fallbackEvidence -and -not $fallbackEvidence.Exists) { + $warningLines.Add('ESP bootx64.efi is missing, so fallback boot relies on the Windows Boot Manager firmware entry.') | Out-Null + } + + foreach ($currentEvidence in @($bootMgrEvidence, $fallbackEvidence, $winloadEvidence)) { + if ($null -eq $currentEvidence) { + continue + } + + $matchingImageEntries = @() + $matchingImageEntryRefs = @() + if ($currentEvidence.Exists -and -not [string]::IsNullOrWhiteSpace($currentEvidence.FileHash)) { + $matchingImageEntries = @($dbxImageHashEntries | Where-Object { $_.HashHex.ToUpperInvariant() -eq $currentEvidence.FileHash.ToUpperInvariant() }) + $matchingImageEntryRefs = @($matchingImageEntries | ForEach-Object { "List $($_.SignatureListIndex), Entry $($_.SignatureEntryIndex)" } | Select-Object -Unique) + } + + $matchingCertificateEntries = @() + $matchingCertificateEntryRefs = @() + foreach ($chainCertificateHash in @($currentEvidence.ChainCertificateHashes)) { + if ([string]::IsNullOrWhiteSpace($chainCertificateHash)) { + continue + } + + $matchingCertificateEntries += @($dbxCertificateHashEntries | Where-Object { + $candidateHash = if (-not [string]::IsNullOrWhiteSpace($_.HashHex)) { + $_.HashHex + } + else { + $_.CertificateSha256 + } + + -not [string]::IsNullOrWhiteSpace($candidateHash) -and $candidateHash.ToUpperInvariant() -eq $chainCertificateHash.ToUpperInvariant() + }) + } + + if ($matchingCertificateEntries.Count -gt 0) { + $matchingCertificateEntryRefs = @($matchingCertificateEntries | ForEach-Object { "List $($_.SignatureListIndex), Entry $($_.SignatureEntryIndex)" } | Select-Object -Unique) + } + + $matchedDbxImage = $matchingImageEntryRefs.Count -gt 0 + $matchedDbxCertificate = $matchingCertificateEntryRefs.Count -gt 0 + + if ($matchedDbxImage) { + $bootExpectation = 'LikelyBlockedByDbx' + $reasonLines.Add("$($currentEvidence.CopyRelativePath) hash matches dbx EFI_CERT_SHA256 entry or entries: $($matchingImageEntryRefs -join '; ').") | Out-Null + } + + if ($matchedDbxCertificate) { + $bootExpectation = 'LikelyBlockedByDbx' + $reasonLines.Add("$($currentEvidence.CopyRelativePath) certificate chain hash matches dbx certificate entry or entries: $($matchingCertificateEntryRefs -join '; ').") | Out-Null + } + + $bootComparisonLines.Add("$($currentEvidence.CopyRelativePath): Exists=$($currentEvidence.Exists); SignatureStatus=$($currentEvidence.SignatureStatus); FileHash=$(if ($currentEvidence.FileHash) { $currentEvidence.FileHash } else { '' }); MatchedDbxImage=$matchedDbxImage; MatchedDbxCertificate=$matchedDbxCertificate; MatchingDbxImageEntries=$(if ($matchingImageEntryRefs.Count -gt 0) { $matchingImageEntryRefs -join '; ' } else { '' }); MatchingDbxCertificateEntries=$(if ($matchingCertificateEntryRefs.Count -gt 0) { $matchingCertificateEntryRefs -join '; ' } else { '' })") | Out-Null + } + + $bcdSummary = if ($BcdDiagnostics.PSObject.Properties['Summary']) { $BcdDiagnostics.Summary } else { $null } + if ($null -eq $bcdSummary) { + if ($bootExpectation -eq 'ExpectedToBootBasedOnCollectedEvidence') { + $bootExpectation = 'Inconclusive' + } + $reasonLines.Add('BCD summary was not available.') | Out-Null + } + else { + if ([string]::IsNullOrWhiteSpace($bcdSummary.BootMgrDevice)) { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add('{bootmgr}.device is missing.') | Out-Null + } + if ([string]::IsNullOrWhiteSpace($bcdSummary.BootMgrPath) -or $bcdSummary.BootMgrPath -ne '\EFI\Microsoft\Boot\bootmgfw.efi') { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add("{bootmgr}.path is '$($bcdSummary.BootMgrPath)' instead of '\EFI\Microsoft\Boot\bootmgfw.efi'.") | Out-Null + } + if ([string]::IsNullOrWhiteSpace($bcdSummary.DefaultDevice)) { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add('{default}.device is missing.') | Out-Null + } + if ([string]::IsNullOrWhiteSpace($bcdSummary.DefaultOsDevice)) { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add('{default}.osdevice is missing.') | Out-Null + } + if ([string]::IsNullOrWhiteSpace($bcdSummary.DefaultPath) -or $bcdSummary.DefaultPath -ne '\Windows\system32\winload.efi') { + $bootExpectation = 'LikelyNotBootable' + $reasonLines.Add("{default}.path is '$($bcdSummary.DefaultPath)' instead of '\Windows\system32\winload.efi'.") | Out-Null + } + } + + if ($reasonLines.Count -eq 0) { + $reasonLines.Add('No direct dbx image-hash or certificate-hash matches were found for bootmgfw.efi, bootx64.efi, or winload.efi, and the collected BCD paths point to bootmgfw.efi and winload.efi.') | Out-Null + $warningLines.Add('OEM-specific UEFI behavior can still prevent boot even when the collected evidence looks correct.') | Out-Null + } + + $expectationLines = @( + "ExpectedBootOutcome: $bootExpectation" + "DbxImageHashEntryCount: $($dbxImageHashes.Count)" + "DbxCertificateEntryCount: $($dbxCertificateHashes.Count)" + "WindowsBootManagerSignatureStatus: $(if ($bootMgrEvidence) { $bootMgrEvidence.SignatureStatus } else { '' })" + "WindowsLoaderSignatureStatus: $(if ($winloadEvidence) { $winloadEvidence.SignatureStatus } else { '' })" + "WindowsFallbackBootSignatureStatus: $(if ($fallbackEvidence) { $fallbackEvidence.SignatureStatus } else { '' })" + ) + + if ($bootComparisonLines.Count -gt 0) { + $expectationLines += '' + $expectationLines += 'BootFileComparison:' + $expectationLines += @($bootComparisonLines) + } + + if ($reasonLines.Count -gt 0) { + $expectationLines += '' + $expectationLines += 'Reasons:' + $expectationLines += @($reasonLines) + } + + if ($warningLines.Count -gt 0) { + $expectationLines += '' + $expectationLines += 'Warnings:' + $expectationLines += @($warningLines) + } + + Write-DiagnosticsTextFile -FilePath (Join-Path -Path $BcdDiagnostics.BcdPath -ChildPath 'boot_expectation.txt') -Content $expectationLines + WriteLog "Boot expectation: $bootExpectation. $($reasonLines -join ' ')" +} + +function Save-BcdDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$StageName, + [Parameter()] + [string]$DiagnosticsRoot, + [Parameter(Mandatory = $true)] + [int]$DiskNumber + ) + + # Capture the final BCD state after boot order configuration. + if ([string]::IsNullOrWhiteSpace($DiagnosticsRoot)) { + WriteLog "BCD diagnostics [$StageName] skipped: diagnostics folder unavailable." + return $null + } + + $stagePath = New-DiagnosticsStageFolder -DiagnosticsRoot $DiagnosticsRoot -StageName $StageName + if ([string]::IsNullOrWhiteSpace($stagePath)) { + WriteLog "BCD diagnostics [$StageName] skipped: stage folder unavailable." + return $null + } + + $bcdPath = Join-Path -Path $stagePath -ChildPath 'BCD' + try { + New-Item -Path $bcdPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + WriteLog "Warning: Failed to create BCD diagnostics folder for stage $StageName. $($_.Exception.Message)" + return $null + } + + $commands = @( + [PSCustomObject]@{ + Label = 'fwbootmgr' + Arguments = '/enum {fwbootmgr} /v' + FileName = 'bcdedit_fwbootmgr_v.txt' + }, + [PSCustomObject]@{ + Label = 'bootmgr' + Arguments = '/enum {bootmgr} /v' + FileName = 'bcdedit_bootmgr_v.txt' + }, + [PSCustomObject]@{ + Label = 'default' + Arguments = '/enum {default} /v' + FileName = 'bcdedit_default_v.txt' + }, + [PSCustomObject]@{ + Label = 'firmware' + Arguments = '/enum firmware /v' + FileName = 'bcdedit_firmware_v.txt' + } + ) + + foreach ($command in $commands) { + Invoke-DiagnosticsCommand -FilePath 'bcdedit.exe' -ArgumentList $command.Arguments -OutputFilePath (Join-Path -Path $bcdPath -ChildPath $command.FileName) -LogLabel "BCD [$StageName] $($command.Label)" | Out-Null + } + + $espAccess = Open-EspPartitionAccess -DiskNumber $DiskNumber + try { + $espBcdPath = if ($null -ne $espAccess) { Join-Path -Path $espAccess.DrivePath -ChildPath 'EFI\Microsoft\Boot\BCD' } else { $null } + $storeOutputPath = Join-Path -Path $bcdPath -ChildPath 'bcdedit_store_all_v.txt' + + if ($espBcdPath -and (Test-Path -Path $espBcdPath -PathType Leaf)) { + $storeArguments = "/store `"$espBcdPath`" /enum all /v" + Invoke-DiagnosticsCommand -FilePath 'bcdedit.exe' -ArgumentList $storeArguments -OutputFilePath $storeOutputPath -LogLabel "BCD [$StageName] store_all" | Out-Null + } + else { + Write-DiagnosticsTextFile -FilePath $storeOutputPath -Content 'ESP BCD store not found.' + WriteLog "BCD [$StageName] ESP store enumeration skipped: offline ESP BCD not found." + } + } + finally { + Close-EspPartitionAccess -EspAccess $espAccess + } + + $summaryObject = Write-BcdSummary -StageName $StageName -BcdPath $bcdPath + + return [PSCustomObject]@{ + StageName = $StageName + StagePath = $stagePath + BcdPath = $bcdPath + Summary = $summaryObject + } +} + function New-DriverSubstMapping { [CmdletBinding()] param( @@ -835,10 +2383,13 @@ $LogFileName = 'ScriptLog.txt' $USBDrive = Get-USBDrive New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null $LogFile = $USBDrive + $LogFilename -$version = '2603.1' +$version = '2603.2' WriteLog 'Begin Logging' WriteLog "Script version: $version" +# Create the per-run diagnostics folder used for Secure Boot telemetry. +$secureBootDiagnosticsPath = New-SecureBootDiagnosticsFolder -UsbDrive $USBDrive + # Display banner and version $banner = @" @@ -933,6 +2484,9 @@ WriteLog "DiskNumber is $DiskID with size $diskSizeGB GB" $sysInfoObject = Get-SystemInformation -HardDrive $hardDrive Write-SystemInformation -SystemInformation $sysInfoObject +# Capture baseline Secure Boot and storage diagnostics before the target disk is wiped. +$null = Invoke-SecureBootDiagnostics -StageName 'Baseline' -DiagnosticsRoot $secureBootDiagnosticsPath -DiskNumber $DiskID -IncludeBootFiles $false + #Find FFU Files Write-SectionHeader 'FFU File Selection' [array]$FFUFiles = @(Get-ChildItem -Path $USBDrive*.ffu) @@ -1525,10 +3079,14 @@ If (Test-Path -Path $WinRE) { WriteLog 'Copying WinRE to Recovery directory succeeded' WriteLog 'Registering location of recovery tools' Invoke-Process W:\Windows\System32\Reagentc.exe "/Setreimage /Path R:\Recovery\WindowsRE /Target W:\Windows" - Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Remove-PartitionAccessPath -AccessPath R: - WriteLog 'Registering location of recovery tools succeeded' -} -#Autopilot JSON + Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Remove-PartitionAccessPath -AccessPath R: + WriteLog 'Registering location of recovery tools succeeded' + } + + # Capture post-apply Secure Boot, storage, and boot-chain diagnostics. + $postApplyDiagnostics = Invoke-SecureBootDiagnostics -StageName 'PostApply' -DiagnosticsRoot $secureBootDiagnosticsPath -DiskNumber $DiskID -WindowsDrivePath 'W:\' + + #Autopilot JSON If ($APFileToInstall) { Write-SectionHeader -Title 'Applying Autopilot Configuration' WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot" @@ -1792,6 +3350,11 @@ Invoke-Process bcdedit.exe "/set {fwbootmgr} displayorder {bootmgr} /addfirst" WriteLog "Setting Windows Boot Manager to be first in the default display order." Write-Host "Setting Windows Boot Manager to be first in the default display order." Invoke-Process bcdedit.exe "/set {bootmgr} displayorder {default} /addfirst" + +# Capture final BCD telemetry after the display order changes are applied. +$finalBcdDiagnostics = Save-BcdDiagnostics -StageName 'FinalBcd' -DiagnosticsRoot $secureBootDiagnosticsPath -DiskNumber $DiskID +Write-BootExpectationSummary -PostApplyDiagnostics $postApplyDiagnostics -BcdDiagnostics $finalBcdDiagnostics + #Copy DISM log to USBDrive WriteLog "Copying dism log to $USBDrive" invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"