Use Proactive remediations to report on or install the Microsoft Update Health tools

Microsoft recently made a download available for their Update Health tools – if you’re using Microsoft Endpoint Manager and enrolling or co-managing Windows devices these tools need to be installed to make use of the capability for expediting quality updates.

For devices connected to Windows Update or Windows Update for Business these tools should already be installed, but in some cases they aren’t – and those devices can’t have updates expedited.

If the tools aren’t installed, MS would like some feedback so they can figure out why – but since they made the tools available for download, we also have the option to manually deploy them if desired, which is something you might want to do when migrating from another software update solution like MEMCM to MEM / WUfB, for example – pre-deploy these tools so they will be ready to use the expedite feature right away.

I created some scripts that can be used with Proactive remediations – the detection script will report on whether the Update Health tools are installed, and optionally you can use the remediation script to go ahead and download and install the tools if they are missing, without needing to package them as an app.

Devices with the tools installed will report the tools version and install date in the Pre-remediation detection output. Devices where the tools are missing will report “Update tools not installed” and if remediated will show the same tools info in the Post-remediation detection output.

I’d suggest to just run a one-time deployment when you need rather than run it repeatedly on a schedule.

MEMCM Compliance Item Scripts to Secure PointAndPrint Registry Keys

Microsoft published some updated guidance yesterday for the Windows Print Spooler Vulnerability (CVE-2021-3457) and recommend securing a couple of Point and Print registry keys if they exist, in addition to deploying the security update:

  • After applying the security update, review the registry settings documented in the CVE-2021-34527 advisory
  • If the registry keys documented do not exist, no further action is required
  • If the registry keys documented exist, in order to secure your system, you must confirm that the following registry keys are set to 0 (zero) or are not present:
    • HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint
    • NoWarningNoElevationOnInstall = 0 (DWORD) or not defined (default setting)
    • UpdatePromptSettings = 0 (DWORD) or not defined (default setting)

I published detection and remediation scripts here which can be used with a compliance item in Microsoft Endpoint Configuration Manager to check if these keys exist and set them to the recommended values if they do. You could also adjust them to run as Proactive remediations scripts in Microsoft Endpoint Manager if that’s your tool.

Querying Windows Build Version History with the Intune Data Warehouse and PowerShell

I had an interesting requirement recently which was to review the OS build numbers of a group of computers over time. This would reveal not only when they got patched, but which patches they installed and when they installed a feature update, for example. Since we aren’t using the Data warehouse in MEMCM and our historic hardware inventory data doesn’t contain the full OS build numbers with the patch level, I turned to MEM and the Intune Data Warehouse to get the information.

A couple of years ago I posted a blog detailing how to query the IDW with PowerShell, using the recommended app registration in Azure and the whole 9 yards, but this time I simplified things a bit.

The collection we need to query is the devicePropertyHistories collection which contains the osVersion property. According to the MS docs, the amount of snapshot data retained varies from table to table, but in my tenant I was able to retrieve 60 days of historic data from this collection.

Unfortunately, the OData query options are limited for the data warehouse, supporting only a subset of the options you might normally use querying Microsoft Graph, for example. This means we basically need to pull in the entire content of the collection, then search on it. We can limit the number of days we want to retrieve data for though, which can speed up the REST API responses.

To start, lets set the $ProgressPreference variable – this speeds up our web requests by not displaying the progress bar.

$ProgressPreference = 'SilentlyContinue'

Then lets define the URL we want to use to connect to the data warehouse. You can get the base URL from the MEM console > Reports > Data warehouse. We’re using the API version 1.0 and I’ve added the devicePropertyHistories collection to the URL.

$DataWarehouseURL = "https://fef.<mytenant>.manage.microsoft.com/ReportingService/DataWarehouseFEService/devicePropertyHistories?api-version=v1.0"
# Optionally set the maximum number of days to query
#$DataWarehouseURL = "https://fef.<mytenant>.manage.microsoft.com/ReportingService/DataWarehouseFEService/devicePropertyHistories?api-version=v1.0&maxhistorydays=14"

Next, let’s get an access token. This assumes that your account has the appropriate permissions or role as detailed here. We’ll use the Az.Account module, so get that installed if you don’t have it.

# Get token for current user (needs appropriate role like Intune Service Administrator)
# Install-Module Az.Accounts
Connect-AzAccount 
$Token = Get-AzAccessToken -ResourceUrl "https://api.manage.microsoft.com/"

Now we’ll prepare the request header, invoke the request, convert and filter the results to find only Windows 10 devices and add them to an arraylist.

$headers = @{'Authorization'="Bearer " + $Token.Token}
$Result = [System.Collections.ArrayList]::new()
Write-host "Processing $DataWarehouseURL"
$WebRequest = Invoke-WebRequest -Uri $DataWarehouseURL -Method Get -Headers $headers
$Content = $WebRequest.Content | ConvertFrom-Json
[void]$Result.Add(($Content.value | Where {$_.osVersion -match "10.0.1" -and $_.jailBroken -eq "Unknown"}))

Because the results are paged and there are more than will be returned in a single request, we loop through the ‘nextLink’ URLs for each request until we’ve built the entire dataset. This can take some time depending how far back you are querying and how much data you have.

Do {
    Write-host "Processing $($Content.'@odata.nextLink')"
    $WebRequest = Invoke-WebRequest -Uri $Content.'@odata.nextLink' -Method Get -Headers $headers
    $Content = $WebRequest.Content | ConvertFrom-Json
    [void]$Result.Add(($Content.value | Where {$_.osVersion -match "10.0.1" -and $_.jailBroken -eq "Unknown"}))
}
While ($null -ne $Content.'@odata.nextLink')

Finally, we flip the arraylist into a regular array (just because I want to :))

$Results = @()
Foreach ($item in $Result)
{
    $Results += $item
}

Now the $Results variable contains our entire dataset, which you can view and query as you wish.

In my case, I want to search for the osVersion changes over time for an individual machine, so:

$ComputerName = 'PC001'
$Records = $Results | 
    Where {$_.deviceName -eq $ComputerName} | 
    Select deviceName,osVersion,@{l='Date';e={(Get-Date ("$(($_.dateKey.ToString()).Substring(0,4))" + " " + "$(($_.dateKey.ToString()).Substring(4,2))" + " " + "$(($_.dateKey.ToString()).Substring(6,2))").ToDateTime((Get-Culture).DateTimeFormat)).ToLongDateString()}}
$Records

The dateKey isn’t in a DateTime friendly format, so we manipulate it a bit to avoid having to cross reference the dates entity.

Viewing the results, I can see this device is on 20H2 and is installing the monthly CU’s not long after they are released

In another example, I can see the device was on 20H2 and got upgraded to 21H1 with the current CU and a couple of days later after ‘patch Tuesday’ (is it a myth?) it got a newer CU.

Finally, for a group of computers I wanted to see what patch level they were at before they got the 20H2 feature update installed, and what patch level they were at after. I prepared a CSV file with computer names, and ran the following code, exporting the results to another CSV file.

$RecordArray = @()
$PCList = Get-Content D:\Temp\Successes.csv -ReadCount 0
foreach ($PC in $PCList)
{
    $PC
    $Records = $Results | Where {$_.deviceName -eq $PC} | Select deviceName,osVersion,@{l='Date';e={(Get-Date ("$(($_.dateKey.ToString()).Substring(0,4))" + " " + "$(($_.dateKey.ToString()).Substring(4,2))" + " " + "$(($_.dateKey.ToString()).Substring(6,2))").ToDateTime((Get-Culture).DateTimeFormat)).ToLongDateString()}}
    If ($Records)
    {
        $UpdateRecord = $Records | Where {$_.osVersion -match "10.0.19042."} | Select -First 1
        $PriorUpdateRecordIndex = $Records.IndexOf($UpdateRecord) -1
        $PriorUpdateRecord = $Records[$PriorUpdateRecordIndex]
        $RecordArray += [PSCustomObject]@{
            deviceName = $PC
            osVersionBeforeUpdate = $PriorUpdateRecord.osVersion
            dateBeforeUpdate = $PriorUpdateRecord.Date
            osVersionAfterUpdate = $UpdateRecord.osVersion
            dateAfterUpdate = $UpdateRecord.Date
        }
    }
}
$RecordArray | Export-CSV  D:\Temp\Successes_Upgrades.csv -Force -NoTypeInformation

This made for some interesting reading!

There’s a wealth of data to access in the Intune Data warehouse and you can use PowerShell, any REST API client, or even Power BI to query and report on the data. Give it a go!

Create custom Intune reports with Microsoft Graph, Azure Automation and Power BI

Microsoft Endpoint Manager aka Intune has been around for a while now and has evolved quite significantly since its early days and the old Silverlight portal (remember that?). Historically Intune hasn’t been particularly good with its reporting capability, but since end 2019 and the announcement of the new reporting framework, things are starting to improve with more built-in reports appearing all the time and now the ability to create your own Azure monitor workbooks from log analytics – welcome additions for sure.

That being said, if you use Power BI as a corporate reporting service there’s no better place to create custom reports according to your needs and share those reports with interested parties without having to give them access to Intune and the MEM portal. Intune data can be queried using Microsoft Graph yet there is currently no native way to use Microsoft Graph as data source in Power BI short of developing a custom connector. With the advent of log analytics data for Intune, we will be able to export log analytics queries to Power BI using M query language which looks promising.

In the meantime, we need to use a little creativity to get data out of Intune and into Power BI to furnish a custom report. I’ve seen examples of using Logic Apps and Function app in Azure as an intermediary process, which is cool. I decided to take a slightly different approach to this and use an Azure automation account to simply export data from Microsoft Graph on a schedule and dump it into an Azure storage account. Power BI supports the use of blob storage as a data source so this works quite nicely.

  • An Azure automation runbook queries Microsoft Graph, organizes the data a bit then exports it into CSV files
  • The CSV files are then uploaded as blobs in a container in a storage account
  • Power BI then uses those CSV files as its datasource allowing us to create custom reports from the data

There are a few advantages to this approach:

  • You can use Power BI to create your own custom reports from any Microsoft Graph data across Microsoft 365, and you can combine data from different sources in a single report
  • Reports support using a scheduled refresh in the Power BI service for keeping data up-to-date
  • Everything is in the cloud – there is no requirement for on-prem resources or a data gateway
  • You can use a managed identity or a Run as account for simplified, secure no-credential authentication
  • Graph data can be manipulated using PowerShell before export allowing customization of the final data set

On the last point, some data in Microsoft Graph contains nested data, eg a field may contain its own set of key-value pairs and you would want to expand these out into their own fields to report on them. Or you may wish to create your own calculated fields from the data, for example how many days since the last sync of an enrolled device.

Additionally Microsoft recently announced support for enabling a managed identity for an automation account, and this is excellent for simplifying and improving the security of access to Microsoft Graph.

The only slight disadvantage to this approach perhaps is that data in the report won’t be live – it will only be as good as your refresh schedule.

Just to give an example, here are a couple of screenshots of a report I created from Intune data for enrolled devices, including Android, iOS and Windows.

I’ve created a new docs site where you can download this report as a template, the runbook used to export the data as well as access detailed instructions on how to set up a data export from MS Graph into Power BI.

docs.smsagent.blog

There’s also a bonus runbook that exports a list of unhealthy MEMCM clients and send it as an email report. We don’t want any unhealthy MEMCM clients now do we?!

Get the current patch level for Windows 10 with PowerShell

I was working on some updates to our unified reporting solution for Windows Updates (ie WUfB + MEMCM) and I wanted to figure out simply from the OS build number whether a Windows 10 workstation has the latest cumulative update installed. The only reliable and useable static list I could find for Windows 10 build numbers is Microsoft’s Windows 10 Update History web page, so I decided to build a PowerShell script that parses the page to get current patch info.

The script below can be used to report which OS build a Windows 10 workstation is currently on as well as which update is the latest update available to the device. It can also report on all Windows updates published for the version of Windows 10 a workstation is currently on.

Run the script as is to show you:

  • Current OS version
  • Current OS Edition
  • Current OS Build number
  • The installed update that corresponds to that build number, as well as the KB number and a link to the info page
  • The latest available update for the OS version
Get-CurrentPatchInfo

Compare the latest available update with the currently installed one to know if the OS is up-to-date.

If there are Preview or Out-of-band updates available that are more recent than the one you have installed, you can exclude those from being reported as a latest available update, so you can just focus on the cumulative updates.

Get-CurrentPatchInfo -ExcludePreview -ExcludeOutofBand

You can also list all Windows updates that Microsoft have published for your OS version like so:

Get-CurrentPatchInfo -ListAvailable

Again focus on just the cumulative updates if you want by excluding Preview and Out-of-band updates from the list:

Get-CurrentPatchInfo -ListAvailable -ExcludePreview -ExcludeOutofBand

Obviously the script does require internet access and I’ve tested it on PowerShell 5.1 and 7.1.

[CmdletBinding()]
Param(
[switch]$ListAllAvailable,
[switch]$ExcludePreview,
[switch]$ExcludeOutofBand
)
$ProgressPreference = 'SilentlyContinue'
$URI = "https://aka.ms/WindowsUpdateHistory" # Windows 10 release history
Function Get-MyWindowsVersion {
[CmdletBinding()]
Param
(
$ComputerName = $env:COMPUTERNAME
)
$Table = New-Object System.Data.DataTable
$Table.Columns.AddRange(@("ComputerName","Windows Edition","Version","OS Build"))
$ProductName = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' Name ProductName).ProductName
Try
{
$Version = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' Name ReleaseID ErrorAction Stop).ReleaseID
}
Catch
{
$Version = "N/A"
}
$CurrentBuild = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' Name CurrentBuild).CurrentBuild
$UBR = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' Name UBR).UBR
$OSVersion = $CurrentBuild + "." + $UBR
$TempTable = New-Object System.Data.DataTable
$TempTable.Columns.AddRange(@("ComputerName","Windows Edition","Version","OS Build"))
[void]$TempTable.Rows.Add($env:COMPUTERNAME,$ProductName,$Version,$OSVersion)
Return $TempTable
}
Function Convert-ParsedArray {
Param($Array)
$ArrayList = New-Object System.Collections.ArrayList
foreach ($item in $Array)
{
[void]$ArrayList.Add([PSCustomObject]@{
Update = $item.outerHTML.Split('>')[1].Replace('</a','').Replace('&#x2014;','')
KB = "KB" + $item.href.Split('/')[-1]
InfoURL = "https://support.microsoft.com" + $item.href
OSBuild = $item.outerHTML.Split('(OS ')[1].Split()[1] # Just for sorting
})
}
Return $ArrayList
}
If ($PSVersionTable.PSVersion.Major -ge 6)
{
$Response = Invoke-WebRequest Uri $URI ErrorAction Stop
}
else
{
$Response = Invoke-WebRequest Uri $URI UseBasicParsing ErrorAction Stop
}
If (!($Response.Links))
{ throw "Response was not parsed as HTML"}
$VersionDataRaw = $Response.Links | where {$_.outerHTML -match "supLeftNavLink" -and $_.outerHTML -match "KB"}
$CurrentWindowsVersion = Get-MyWindowsVersion ErrorAction Stop
If ($ListAllAvailable)
{
If ($ExcludePreview -and $ExcludeOutofBand)
{
$AllAvailable = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Preview" -and $_.outerHTML -notmatch "Out-of-band"}
}
ElseIf ($ExcludePreview)
{
$AllAvailable = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Preview"}
}
ElseIf ($ExcludeOutofBand)
{
$AllAvailable = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Out-of-band"}
}
Else
{
$AllAvailable = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0]}
}
$UniqueList = (Convert-ParsedArray Array $AllAvailable) | Sort OSBuild Descending Unique
$Table = New-Object System.Data.DataTable
[void]$Table.Columns.AddRange(@('Update','KB','InfoURL'))
foreach ($Update in $UniqueList)
{
[void]$Table.Rows.Add(
$Update.Update,
$Update.KB,
$Update.InfoURL
)
}
Return $Table
}
$CurrentPatch = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'} | Select First 1
If ($ExcludePreview -and $ExcludeOutofBand)
{
$LatestAvailablePatch = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Out-of-band" -and $_.outerHTML -notmatch "Preview"} | Select First 1
}
ElseIf ($ExcludePreview)
{
$LatestAvailablePatch = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Preview"} | Select First 1
}
ElseIf ($ExcludeOutofBand)
{
$LatestAvailablePatch = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0] -and $_.outerHTML -notmatch "Out-of-band"} | Select First 1
}
Else
{
$LatestAvailablePatch = $VersionDataRaw | where {$_.outerHTML -match $CurrentWindowsVersion.'OS Build'.Split('.')[0]} | Select First 1
}
$Table = New-Object System.Data.DataTable
[void]$Table.Columns.AddRange(@('OSVersion','OSEdition','OSBuild','CurrentInstalledUpdate','CurrentInstalledUpdateKB','CurrentInstalledUpdateInfoURL','LatestAvailableUpdate','LastestAvailableUpdateKB','LastestAvailableUpdateInfoURL'))
[void]$Table.Rows.Add(
$CurrentWindowsVersion.Version,
$CurrentWindowsVersion.'Windows Edition',
$CurrentWindowsVersion.'OS Build',
$CurrentPatch.outerHTML.Split('>')[1].Replace('</a','').Replace('&#x2014;',''),
"KB" + $CurrentPatch.href.Split('/')[-1],
"https://support.microsoft.com" + $CurrentPatch.href,
$LatestAvailablePatch.outerHTML.Split('>')[1].Replace('</a','').Replace('&#x2014;',''),
"KB" + $LatestAvailablePatch.href.Split('/')[-1],
"https://support.microsoft.com" + $LatestAvailablePatch.href
)
Return $Table

Deploying HP BIOS Updates – a real world example

Not so long ago HP published a customer advisory listing a number of their models that need to be on the latest BIOS release to be upgraded to Windows 10 2004. Since we were getting ready to rollout 20H2 we encountered some affected models in piloting, which prompted me to find that advisory and then get the BIOS updated on affected devices.

To be honest, until now we’ve never pushed out BIOS updates to anyone, but to get these devices updated to 20H2 we now had no choice. In this post I’m just going to share how we did that. For us, good user experience is critical but finding the balance between keeping devices secure and up-to-date without being too disruptive to the user can be a challenge!

First off was to create a script that could update the BIOS on any supported HP workstation, without needing to package and distribute BIOS update content. I know other equally handsome community members have shared some great scripts and solutions for doing BIOS updates, but I decided to create my own in this instance to meet our particular requirements and afford a bit more control over the update process. I considered using the HP Client Management Script Library which seems purpose-built for this kind of task and is a great resource, but I preferred not to have the dependency of an external PowerShell module and its requirements.

I published a version of this script in Github here. The script does the following:

  • Creates a working directory under ProgramData
  • Disables the IE first run wizard which causes a problem for the Invoke-WebRequest cmdlet when running in a context where IE hasn’t been initialised
  • Checks the HP Image Assistant (HPIA) web page for the latest version and downloads it
  • Extracts and runs the HPIA to identify the latest applicable BIOS update (if any)
  • If a BIOS update is available, downloads and extracts the softpaq
  • Checks if a BIOS password has been set, if so creates an encrypted password file as required by the firmware update utility
  • Runs the firmware update utility to stage the update
  • Everything is logged to the working directory and the logs are uploaded to Azure blob storage upon completion, or if something fails, so we can review them without requiring remote access to the user’s computer

It all runs silently without any user interaction and it can be run on any HP model that the HPIA supports.

In Production however, we used a slightly modified version of this script. Since there was the possibility that there could be unknown BIOS passwords in use out there, we decided not to try to flash the BIOS using an encrypted password file, but instead try to remove the BIOS password altogether (temporarily!) When the BIOS update is staged it simply copies the password file to the staging volume – it doesn’t check whether the password is correct or not. If it is not correct, the user would then be asked for the correct password when the BIOS is flashed and that is not cool! Removing the password meant that the user could never be unexpectedly prompted for the password in the event that the provided password file was incorrect. Of course, to remove the password you also have to know the password, so we tried the ones we knew and if they worked, great, if they didn’t, the script would simply exit out as a failsafe.

We have a compliance baseline deployed with MEMCM that sets the BIOS password on any managed workstation that does not have one set, so after the machine rebooted, the BIOS flashed and machine starts up again, before long the CB would run and set the password again.

Doing this also meant that we needed to ensure the computer was restarted asap after the update was staged – and for another reason as well – Bitlocker encryption is suspended until the update is applied and the machine restarted.

Because we didn’t want to force the update on users and force a restart on them, we decided to package the script as an application in MEMCM. This meant a couple of things:

  • We could put a nice corporate logo on the app and make it available for install in the Software Center
  • We could handle the return codes with custom actions. In this case, we are expecting the PowerShell script to exit successfully with code 0, and when it does we’ve set that code to be a soft reboot so that MEMCM restart notifications are then displayed to the user.

As a detection method, the following PowerShell code was used. This simply detects if the last write time of the log file has changed within the last hour, if it has, it’s installed. Longer than an hour and it’s available for install again if needed.

$Log = Get-ChildItem C:\ProgramData\Contoso\HP_BIOS_Update -Recurse -Include HP_BIOS_Update.log -ErrorAction SilentlyContinue | 
    where {([DateTime]::Now - $_.LastWriteTime).TotalHours -le 1}
If ($Log)
{
    Write-Host "Installed"
}

We then deployed the application with an available deployment. We communicated with the users directly to inform them a BIOS update needed to be installed on their device in order to remain secure and up-to-date, and directed them to the Software Center to install it themselves.

We also prepared collections in SCCM using query-based membership rules to identify the machines that were affected by the HP advisory, and an SQL query to find the same information and pull the full user name and email address from inventoried data.

The script does contain the BIOS password in clear text which, or course, may not meet your security requirements, although for us this password is not really that critical – it’s just there to help prevent the user from making unauthorized changes in the BIOS. In our Production script though, we simply converted these to base64 before adding them to the script to at least provide some masking. But for greater security you could consider storing the password in Azure key vault and fetch it at run time with a web request, for example.

If you wish to use the script in your own environment, you’ll need to change the location of the working directory as you desire. Additionally if you wish to upload the log files to an Azure storage container, you’ll need to have or create a container and add the URL and the SAS token string to the script, or else just comment out the Upload-LogFilesToAzure function where it’s used. I’m a big fan of sending log files to Azure storage especially during this season where many are working from home and may not be corporate connected. You can just use Azure Storage Explorer to download the log files which will open up in CMTrace if that’s your default log viewer.

Hope this is helpful to someone! The PS script is below.

#####################
## HP BIOS UPDATER ##
#####################
# Params
$HPIAWebUrl = "https://ftp.hp.com/pub/caps-softpaq/cmit/HPIA.html" # Static web page of the HP Image Assistant
$BIOSPassword = "MyPassword"
$script:ContainerURL = "https://mystorageaccount.blob.core.windows.net/mycontainer" # URL of your Azure blob storage container
$script:FolderPath = "HP_BIOS_Updates" # the subfolder to put logs into in the storage container
$script:SASToken = "mysastoken" # the SAS token string for the container (with write permission)
$ProgressPreference = 'SilentlyContinue' # to speed up web requests
################################
## Create Directory Structure ##
################################
$RootFolder = $env:ProgramData
$ParentFolderName = "Contoso"
$ChildFolderName = "HP_BIOS_Update"
$ChildFolderName2 = Get-Date Format "yyyy-MMM-dd_HH.mm.ss"
$script:WorkingDirectory = "$RootFolder\$ParentFolderName\$ChildFolderName\$ChildFolderName2"
try
{
[void][System.IO.Directory]::CreateDirectory($WorkingDirectory)
}
catch
{
throw
}
# Function write to a log file in ccmtrace format
Function script:Write-Log {
param (
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter()]
[ValidateSet(1, 2, 3)] # 1-Info, 2-Warning, 3-Error
[int]$LogLevel = 1,
[Parameter(Mandatory = $true)]
[string]$Component,
[Parameter(Mandatory = $false)]
[object]$Exception
)
$LogFile = "$WorkingDirectory\HP_BIOS_Update.log"
If ($Exception)
{
[String]$Message = "$Message" + "$Exception"
}
$TimeGenerated = "$(Get-Date Format HH:mm:ss).$((Get-Date).Millisecond)+000"
$Line = '<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="" type="{4}" thread="" file="">'
$LineFormat = $Message, $TimeGenerated, (Get-Date Format MMddyyyy), $Component, $LogLevel
$Line = $Line -f $LineFormat
# Write to log
Add-Content Value $Line Path $LogFile ErrorAction SilentlyContinue
}
# Function to upload log file to Azure Blob storage
Function Upload-LogFilesToAzure {
$Date = Get-date Format "yyyy-MM-dd_HH.mm.ss"
$HpFirmwareUpdRecLog = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.log Recurse ErrorAction SilentlyContinue
$HPBIOSUPDRECLog = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC64.log Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRecLog)
{
$File = $HpFirmwareUpdRecLog
}
ElseIf ($HPBIOSUPDRECLog)
{
$File = $HPBIOSUPDRECLog
}
Else{}
If ($File)
{
$Body = Get-Content $($File.FullName) Raw ErrorAction SilentlyContinue
If ($Body)
{
$URI = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File.Name)$SASToken"
$Headers = @{
'x-ms-content-length' = $($File.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI Method PUT Headers $Headers Body $Body ErrorAction SilentlyContinue
}
}
$File2 = Get-Item $WorkingDirectory\HP_BIOS_Update.log ErrorAction SilentlyContinue
$Body2 = Get-Content $($File2.FullName) Raw ErrorAction SilentlyContinue
If ($Body2)
{
$URI2 = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File2.Name)$SASToken"
$Headers2 = @{
'x-ms-content-length' = $($File2.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI2 Method PUT Headers $Headers2 Body $Body2 ErrorAction SilentlyContinue
}
}
Write-Log Message "#######################" Component "Preparation"
Write-Log Message "## Starting BIOS update run ##" Component "Preparation"
Write-Log Message "#######################" Component "Preparation"
#################################
## Disable IE First Run Wizard ##
#################################
# This prevents an error running Invoke-WebRequest when IE has not yet been run in the current context
Write-Log Message "Disabling IE first run wizard" Component "Preparation"
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft" Name "Internet Explorer" Force
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer" Name "Main" Force
$null = New-ItemProperty Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer\Main" Name "DisableFirstRunCustomize" PropertyType DWORD Value 1 Force
##########################
## Get latest HPIA Info ##
##########################
Write-Log Message "Finding info for latest version of HP Image Assistant (HPIA)" Component "DownloadHPIA"
try
{
$HTML = Invoke-WebRequest Uri $HPIAWebUrl ErrorAction Stop
}
catch
{
Write-Log Message "Failed to download the HPIA web page. $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
$HPIASoftPaqNumber = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).outerText
$HPIADownloadURL = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).href
$HPIAFileName = $HPIADownloadURL.Split('/')[-1]
Write-Log Message "SoftPaq number is $HPIASoftPaqNumber" Component "DownloadHPIA"
Write-Log Message "Download URL is $HPIADownloadURL" Component "DownloadHPIA"
###################
## Download HPIA ##
###################
Write-Log Message "Downloading the HPIA" Component "DownloadHPIA"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$HPIAFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadHPIA" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $HPIADownloadURL Destination $WorkingDirectory\$HPIAFileName Asynchronous DisplayName "$HPIAFileName" Description "HPIA download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadHPIA"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadHPIA"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadHPIA"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the HPIA: $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
##################
## Extract HPIA ##
##################
Write-Log Message "Extracting the HPIA" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\$HPIAFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\HPIA\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\HPIA\HPImageAssistant.exe)
{
Write-Log Message "Extraction complete" Component "Analyze"
}
Else
{
Write-Log Message "HPImageAssistant not found!" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to extract the HPIA: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
##############################################
## Analyze available BIOS updates with HPIA ##
##############################################
Write-Log Message "Analyzing system for available BIOS updates" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\HPIA\HPImageAssistant.exe WorkingDirectory $WorkingDirectory ArgumentList "/Operation:Analyze /Category:BIOS /Selection:All /Action:List /Silent /ReportFolder:$WorkingDirectory\Report" NoNewWindow PassThru Wait ErrorAction Stop
If ($Process.ExitCode -eq 0)
{
Write-Log Message "Analysis complete" Component "Analyze"
}
elseif ($Process.ExitCode -eq 256)
{
Write-Log Message "The analysis returned no recommendation. No BIOS update is available at this time" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
Exit 0
}
elseif ($Process.ExitCode -eq 4096)
{
Write-Log Message "This platform is not supported!" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
throw
}
Else
{
Write-Log Message "Process exited with code $($Process.ExitCode). Expecting 0." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to start the HPImageAssistant.exe: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
# Read the XML report
Write-Log Message "Reading xml report" Component "Analyze"
try
{
$XMLFile = Get-ChildItem Path "$WorkingDirectory\Report" Recurse Include *.xml ErrorAction Stop
If ($XMLFile)
{
Write-Log Message "Report located at $($XMLFile.FullName)" Component "Analyze"
try
{
[xml]$XML = Get-Content Path $XMLFile.FullName ErrorAction Stop
$Recommendation = $xml.HPIA.Recommendations.BIOS.Recommendation
If ($Recommendation)
{
$CurrentBIOSVersion = $Recommendation.TargetVersion
$ReferenceBIOSVersion = $Recommendation.ReferenceVersion
$DownloadURL = "https://" + $Recommendation.Solution.Softpaq.Url
$SoftpaqFileName = $DownloadURL.Split('/')[-1]
Write-Log Message "Current BIOS version is $CurrentBIOSVersion" Component "Analyze"
Write-Log Message "Recommended BIOS version is $ReferenceBIOSVersion" Component "Analyze"
Write-Log Message "Softpaq download URL is $DownloadURL" Component "Analyze"
}
Else
{
Write-Log Message "Failed to find a BIOS recommendation in the XML report" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to parse the XML file: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
Else
{
Write-Log Message "Failed to find an XML report." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to find an XML report: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
###############################
## Download the BIOS softpaq ##
###############################
Write-Log Message "Downloading the Softpaq" Component "DownloadBIOSUpdate"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$SoftpaqFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadBIOSUpdate" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $DownloadURL Destination $WorkingDirectory\$SoftpaqFileName Asynchronous DisplayName "$SoftpaqFileName" Description "BIOS update download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadBIOSUpdate"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadBIOSUpdate"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadBIOSUpdate"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the BIOS update: $($_.Exception.Message)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#########################
## Extract BIOS Update ##
#########################
Write-Log Message "Extracting the BIOS Update" Component "ExtractBIOSUpdate"
$BIOSUpdateDirectoryName = $SoftpaqFileName.Split('.')[0]
try
{
$Process = Start-Process FilePath $WorkingDirectory\$SoftpaqFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\$BIOSUpdateDirectoryName\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
$HpFirmwareUpdRec = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.exe Recurse ErrorAction SilentlyContinue
$HPBIOSUPDREC = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC.exe Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRec)
{
$BIOSExecutable = $HpFirmwareUpdRec
}
ElseIf ($HPBIOSUPDREC)
{
$BIOSExecutable = $HPBIOSUPDREC
}
Else
{
Write-Log Message "BIOS update executable not found!" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Extraction complete" Component "ExtractBIOSUpdate"
}
catch
{
Write-Log Message "Failed to extract the softpaq: $($_.Exception.Message)" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#############################
## Check for BIOS password ##
#############################
try
{
$SetupPwd = (Get-CimInstance Namespace ROOT\HP\InstrumentedBIOS ClassName HP_BIOSPassword Filter "Name='Setup Password'" ErrorAction Stop).IsSet
If ($SetupPwd -eq 1)
{
Write-Log Message "The BIOS has a password set" Component "BIOSPassword"
$BIOSPasswordSet = $true
}
Else
{
Write-Log Message "No password has been set on the BIOS" Component "BIOSPassword"
}
}
catch
{
Write-Log Message "Unable to determine if a BIOS password has been set: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
##########################
## Create password file ##
##########################
If ($BIOSPasswordSet)
{
Write-Log Message "Creating an encrypted password file" Component "BIOSPassword"
$HpqPswd = Get-ChildItem Path $WorkingDirectory Include HpqPswd.exe Recurse ErrorAction SilentlyContinue
If ($HpqPswd)
{
try
{
$Process = Start-Process FilePath $HpqPswd.FullName WorkingDirectory $WorkingDirectory ArgumentList "-p""$BIOSPassword"" -f.\password.bin -s" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\password.bin)
{
Write-Log Message "File successfully created" Component "BIOSPassword"
}
Else
{
Write-Log Message "Encrypted password file could not be found!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to create an encrypted password file: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
else
{
Write-Log Message "Failed to locate HP password encryption utility!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
###########################
## Stage the BIOS update ##
###########################
Write-Log Message "Staging BIOS firmware update" Component "BIOSFlash"
try
{
If ($BIOSPasswordSet)
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -p.\password.bin -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
Else
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
If ($Process.ExitCode -eq 3010)
{
Write-Log Message "The update has been staged. The BIOS will be updated on restart" Component "BIOSFlash"
}
Else
{
Write-Log Message "An unexpected exit code was returned: $($Process.ExitCode)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to stage BIOS update: $($_.Exception.Message)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "This BIOS update run is complete. Have a nice day!" Component "Completion"
UploadLogFilesToAzure

Calculating the Offline Time for a Windows 10 Upgrade

For my Windows 10 feature update installation process, I like to gather lots of statistics around the upgrade itself as well as the devices they are running on so we can later report on these. These stats can be useful for identifying areas of potential improvement in the upgrade process. One stat I gather is the offline time for the upgrade, ie the time between when the downlevel (online) phase is completed and the computer is restarted and the time when the offline phases have completed and the OS is brought back to the logon screen again. Knowing this value across the estate helps to gauge the user experience and how much time is being spent waiting for the offline phases to complete.

To calculate this value is actually straightforward – you can do it by searching the SYSTEM event log for the last time the computer was restarted and comparing it with the installation time of the OS which gets recorded in WMI after the offline phases have completed successfully. The only thing is, after the offline phase is complete the event logs are refreshed and previous log entries are removed, so you have to search the event log in the Windows.old folder instead. You have to do this before the Windows.old folder gets automatically removed (depending on your policy) and manual rollback is no longer possible.

The PowerShell code below searches for the most recent event ID 1074, compares the date with the OS install date value in WMI (use the *CIM* cmdlets to get an automatic conversion to [DateTime]) and outputs as a TimeSpan which you can log however you want.

The good news is that for a 20H2 upgrade from media – at least in my various tests – the offline time has been impressively low.

$Params = @{
    Path = "$env:SystemDrive\Windows.old\Windows\System32\winevt\Logs\System.evtx"
    Id = 1074
}
$LatestRestartEvent = (Get-WinEvent -FilterHashtable $Params -ErrorAction SilentlyContinue | Select -First 1)
$InstallFinishedDate = Get-CimInstance Win32_OperatingSystem | Select -ExpandProperty InstallDate
If ($LatestRestartEvent)
{
    $UpgradeOfflineTime = $InstallFinishedDate - $LatestRestartEvent.TimeCreated
}

Windows 10 Upgrades – Dealing with Safeguard ID 25178825 (Conexant ISST Driver)

I saw a tweet recently from Madhu Sanke where he had deployed updated Conexant ISST drivers to his environment to release devices from the Safeguard ID 25178825 which at the time of writing still prevents devices trying to upgrade to 2004 or 20H2.

You can read more about the issue here, but around September/October 2020 timeframe, newer drivers became available that are not affected by this Safeguard and updating the drivers will release a device from this hold.

Madhu took the approach of downloading the drivers from the Microsoft Update Catalog and packaging them with a script wrapper for the install. In researching this myself, I found that there are more than one driver available and different models will take different drivers, so I decided to write a little C# program that will update a device using Windows Update directly instead.

The program simply connects to Windows Update online, checks if a newer driver version is available for the Conexant ISST audio driver (listed under ‘Conexant – MEDIA – ‘ in the MS update catalog), and downloads and installs it.

The executable can then be deployed as is using a product like Microsoft Endpoint Configuration Manager.

For environments where software updates are deployed with Configuration Manager / WSUS, the program will check if registry keys have been set preventing access to Windows Update online and temporarily open them. It will restore the previous settings after updating.

The program also logs to the Temp directory.

On my HP laptops, the driver in question is this guy:

This one has been updated and is no longer affected by the Safeguard.

You can download the C# executable from my GitHub repo, or you can clone the solution in Visual Studio and compile your own executable. Just remember to run the program in SYSTEM context or with administrative privilege on the client as it’s installing a driver.

A reboot is usually required after installation and the driver can display a small one-time toast notification like this:

If your devices are managed by Windows Update for Business, you may see a notification from there as well, depending on your configuration of course.

After deploying the driver update to affected devices, expect to see them being released from the Safeguard after telemetry has run. You can use my PowerBI reports to help with reporting on Safeguards in your environment.

Thanks again to Madhu for the inspiration!

Getting Creative: a Bespoke Solution for Feature Update Deployments

This is the first blog post in what I hope will be a series of posts demonstrating several custom solutions I created for things such as feature update deployments, managing local admin password rotation, provisioning Windows 10 devices, managing drive mappings and more. My reasons for creating these solutions was to overcome some of the current limitations in existing products or processes, make things more cloud-first and independent of existing on-prem infrastructure where possible, and to more exactly meet the requirements of the business.

Although I will try to provide a generalised version of the source code where possible, I am not providing complete solutions that you can go ahead and use as is. Rather my intention is to inspire your own creativity, to give working examples of what could be done if you have the time and resource, and to provide source code as a reference or starting point for your own solutions should you wish to create them!

Someone asked me recently how we deploy feature updates and it was a difficult question to answer other than to say we use a custom-built process. Having used some of the existing methods available (ConfigMgr Software Updates, ConfigMgr custom WaaS process, Windows Update for Business) we concluded there were shortcomings in each of them, and this provided inspiration to create our own, customized process to give us the control, reliability, user experience and reporting capability that we desired. Don’t get me wrong – I am not saying these methods aren’t good – they just couldn’t do things exactly the way we wanted.

So I set out to create a bespoke process – one that we could customize according to our needs, that was largely independent of our existing Configuration Manager infrastructure and that could run on any device with internet access. This required making use of cloud services in Azure as well as a lot of custom scripting! In this blog, I’ll try to cover what I did and how it works.

User Experience

First, let’s look at the end user experience of the feature update installation process – this was something key for us, to improve the user experience keeping it simple yet informative, and able to respond appropriately to any upgrade issues.

Once the update is available to a device, a toast notification is displayed notifying the user that an update is available. Initially, this displays once a day and automatically dismisses after 25 seconds. (I’ve blanked out our corporate branding in all these images)

We use a soft deadline – ie the update is never forced on the user. Enforcing compliance is handled by user communications and involvement from our local technicians. With one week left before the deadline, we increase the frequency of the notifications to twice per day.

If the deadline has passed, we take a more aggressive approach with the notifications, modifying the image and text, displaying it every 30 minutes and it doesn’t leave the screen unless the user actions or dismisses it.

The update can be installed via a shortcut on the desktop, or in the last notification it can be initiated from the notification itself.

Once triggered, a custom UI is displayed introducing the user to the update and what to expect.

When the user clicks Begin, we check that a power adapter is connected and no removable USB devices are attached – if they are, we prompt to the user to remove them first.

The update runs in three phases or stages – these correspond to the PreDownload, Install and Finalize commands on the update (more on that later). The progress of each stage is polled from the registry, as is the Setup Phase and Setup SubPhase.

Note that the user cannot cancel the update once it starts and this window will remain on the screen and on top of all other windows until the update is complete. The user can click the Hide me button, and this will shrink the window like so:

This little window also cannot be removed from the screen, but it can be moved around and is small enough to be unobtrusive. When the update has finished installing, or when the user clicks Restore, the main window will automatically display again and report the result of the update.

The colour scheme is based on Google’s material design, by the way.

If the update failed during the online phase, the user can still initiate the update from the desktop shortcut but toast notifications will no longer display as reminders. The idea is that IT can attempt to remediate the device and run the update again after.

If successful, the user can click Restart to restart the computer immediately. Then the offline phase of the upgrade runs, where you see the usual light blue screen and white Windows update text reporting that updates are being installed.

Once complete, the user will be brought back to the login screen, and we won’t bother them anymore.

If the update rolled back during the offline phase, we will detect this next time they log in and notify them one time:

Logging and Reporting

The entire update process is logged right from the start to a log file on the local machine. We also send ‘status messages’ at key points during the process and these find their way to an Azure SQL database which becomes the source for reporting on update progress across the estate (more on this later).

A PowerBI report gives visual indicators of update progress as well as a good amount of detail from each machine including update status, whether it passed or failed readiness checks and if failed, why, whether it passed the compatibility assessment, if it failed the assessment or the install we give the error code, whether any hard blocks were found, setup diag results (2004 onward), how long the update took to install and a bunch of other stuff we find useful.

Since 2004 though, we have starting inventorying certain registry keys using ConfigMgr to give us visibility of devices that won’t upgrade because of a Safeguard hold or other reason, so we can target the upgrade only to devices that aren’t reporting any known compatibility issues.

If a device performs a rollback, we can get it to upload key logs and registry key dumps to an Azure storage account where an administrator can remotely diagnose the issue.

How does it work?

Now lets dive into the process in more technical detail.

Deployment Script

The update starts life with a simple PowerShell script that does the following:

  • Creates a local directory to use to cache content, scripts and logs etc
  • Temporarily stores some domain credentials in the registry of the local SYSTEM account as encrypted secure strings for accessing content from a ConfigMgr distribution point if necessary (more on this later)
  • Downloads a manifest file that contains a list of all files and file versions that need to be downloaded to run the update. These include scripts, dlls (for the UI), xml definition files for scheduled tasks etc
  • Each file is then downloaded to the cache directory from an Azure CDN
  • 3 scheduled tasks are then registered on the client:
    • A ‘preparer’ task which runs prerequisite actions
    • A ‘file updater’ task which keeps local files up-to-date in case we wish to change something
    • A ‘content cleanup’ task which is responsible for cleaning up in the event the device gets upgraded through any means
  • A ‘status message’ is then sent as an http request, creating a new record for the device in the Azure SQL database

This script can be deployed through any method you wish, including Configuration Manager, Intune or just manually, however it should be run in SYSTEM context.

Content

All content needed for the update process to run is put into a container in a storage account in Azure. This storage account is exposed via an Azure Content Delivery Network (CDN). This means that clients can get all the content they need directly from an internet location with minimal latency no matter where they are in the world.

Feature Update Files

The files for the feature update itself are the ESD file and WindowsUpdateBox.exe that Windows Update uses. You can get these files from Windows Update, WSUS, or as in our case, from Configuration Manager via WSUS. We simply download the feature updates to a deployment package in ConfigMgr and grab the content from there.

You could of course use an ISO image and run setup.exe, but the ESD files are somewhat smaller in size and are sufficient for purpose.

The ESD files are put into the Azure CDN so the client can download them from there, but we also allow the client the option to get the FU content from a local ConfigMgr distribution point if they are connected to the corporate network locally. Having this option allows considerably quicker content download. Since IIS on the distribution points is not open to anonymous authentication, we use the domain credentials stamped to the registry to access the DP and download the content directly from IIS (credentials are cleaned from the registry after use).

Status Messages

Similar to how ConfigMgr sends status message to a management point, this solution also send status messages at key points during the process. This works by using Azure Event Grid to receive the message sent from the client as an http request. The Event Grid sends the message to an Azure Function, and the Azure Function is responsible to update the Azure SQL database created for this purpose with the current upgrade status of the device. The reason for doing it this way is that sending an http request to Event Grid is very quick and doesn’t hold up the process. Event Grid forwards the message to the Azure Function and can retry the message in the case it can’t get through immediately (although I’ve never experienced any failures or dead-lettering in practice). The Azure Function uses a Managed Identity to access the SQL database, which means the SQL database never needs to be exposed outside of its sandbox in Azure, and no credentials are needed to update the database.

We then use PowerBI to report on the data in the database to give visibility of where in the process every device is, if there are any issues that need addressing and all the stats that are useful for understanding whether devices get content from Azure or a local DP, what their approximate bandwidth is, how long downloads took, whether they were wired or wireless, make and model, upgrade time etc.

Preparation Script

After the initial deployment script has run, the entire upgrade process is driven by scheduled tasks on the client. The first task to run is the Preparation script and this attempts to run every hour until successful completion. This script does the following things:

  • Create the registry keys for the upgrade. These keys are stamped with the update progress and the results of the various actions such as pre-req checks, downloads etc. When we send a ‘status message’ we simply read these keys and send them on. Having progress stamped in the local registry is useful if we need to troubleshoot on the device directly.
  • Run readiness checks, such as
    • Checking for client OS
    • Checking disk space
  • Check for internet connectivity
  • Determine the approximate bandwidth to the Azure CDN and measure latency. This is done by downloading a 100MB file from the CDN and timing the download and using ‘psping.exe’ to measure latency. From this, we can calculate an approximate download time for the main ESD file.
  • Determine if the device is connected by wire or wireless
  • Determine if the device is connected to the corporate network
  • If the device is on the corporate network, we check latency to all the ConfigMgr distribution points to determine which one will be the best DP to get content from
  • Determine whether OS is business or consumer and which language. This helps us figure out which ESD file to use.
  • Download WindowsUpdateBox.exe and verify the hash
  • Download the feature update ESD file and verify the hash
    • Downloads of FU content is done using BITS transfers as this proved the most reliable method. Code is added to handle BITS transfer errors to add resilience.
  • Assuming all the above is done successfully, the Preparation task will be disabled and the PreDownload task created.

PreDownload Script

The purpose of this script is to run the equivalent of a compatibility assessment. When using the ESD file, this is done with the /PreDownload switch on WindowsUpdateBox.exe. Should the PreDownload fail, the error code will be logged to the registry. Since 2004, we also read the SetupDiag results and stamp these to the registry. We also check the Compat*.xml files to look for any hard blocks and if found, we log the details to the registry.

If the PreDownload failed, we change the schedule of the task to run twice a week. This allows for remediation to be performed on the device before attempting the PreDownload assessment again.

If the PreDownload succeeds, we disable the PreDownload task and create two new ones – a Notification task and an Upgrade task.

We also create a desktop shortcut that the user can use to initiate the upgrade.

Notification Script

The Notification script runs in the user context and displays toast notifications to notify the user that the upgrade is available, what the deadline is and how to upgrade, as already mentioned.

Upgrade Script

When the user clicks the desktop shortcut or the ‘Install now’ button on the toast notification, the upgrade is initiated. Because the upgrade needs to run with administrative privilege, the only thing the desktop shortcut and toast notification button does is to create an entry in the Application event log. The upgrade scheduled task is triggered when this event is created and the task runs in SYSTEM context. The UI is displayed in the user session with the help of the handy ServiceUI.exe from the MDT toolkit.

Upgrade UI

The user interface part of the upgrade is essentially a WPF application coded in PowerShell. The UI displays some basic upgrade information for the user and once they click ‘Begin’ we run the upgrade in 3 stages:

  1. PreDownload. Even though we ran this already, we run again before installing just to make sure nothing has changed since, and it doesn’t take long to run.
  2. Install. This uses the /Install switch on WindowsUpdateBox.exe and runs the main part of the online phase of the upgrade.
  3. Finalize. This uses the /Finalize switch and finalizes the update in preparation for a computer restart.

The progress of each of these phases is tracked in the registry and displayed in the UI using progress bars. If there is an issue, we notify the user and IT can get involved to remediate.

If successful, the user can restart the computer immediately or a later point (though we discourage this!). We don’t stop the user from working while the upgrade is running in the online phase and we allow them to partially hide the upgrade window so the upgrade does not hinder user productivity (similar to how WUfB installs an update in the background.)

After the user restarts the computer, the usual Windows Update screens take over until the update has installed and the user is brought to the login screen again.

Drivers and Stuff

We had considered upgrading drivers and even apps with this process, as we did for the 1903 upgrade, however user experience was important for us and we didn’t want the upgrade to take any longer than necessary, so we decided not to chain anything onto the upgrade process itself but handle other things separately. That being said, because this is a custom solution it is perfectly possible to incorporate additional activities into it if desired.

Rollback

In the event the that OS was rolled back during the offline phase, a scheduled task will run that will detect this and raise a toast notification to inform the user. We have a script that will gather logs and data from the device and upload it to a storage account in Azure where an administrator can remotely diagnose the issue. I plan to incorporate that as an automatic part of the process in a future version.

Updater Script

The solution creates an Updater scheduled task which runs once per day. The purpose of this task is to keep the solution up to date. If we want to change something in the process, add some code to a file or whatever is necessary, the Updater will take care of this.

It works by downloading a manifest file from the Azure CDN. This file contains all the files used by the solution with their current versions. If we update something, we upload the new files to the Azure storage account, purge them from the CDN and update the manifest file.

The Updater script will download the current manifest, detect that something has changed and download the required files to the device.

Cleanup Script

A Cleanup task is also created. When this task detects that the OS has been upgraded to the required version, it will remove all the scheduled tasks and cached content to leave no footprint on the device other than the log file and the registry keys.

Source Files

You can find a generalised version of the code used in this solution in my Github repo as a reference. As mentioned before though, there are many working parts to the solution including the Azure services and I haven’t documented their configuration here.

Final Comments

The main benefit of this solution for us is that it is completely customised to our needs. Although it is relatively complex to create, it is also relatively easy to maintain as well as adapt the solution for new W10 versions. We do still take advantage of products like ConfigMgr to allow devices to get content from a local DP if they are corporate connected, and ConfigMgr / Update Compliance / Desktop Analytics for helping us determine device compatibility and ConfigMgr or Intune to actually get the deployment script to the device. We also make good use of Azure services for the status messages and the cloud database, as well as PowerBI for reporting. So the solution still utilizes existing Microsoft products while giving us the control and customisations that we need to provide a better upgrade experience for our users.