Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 33 additions & 44 deletions Invoke-CMDownloadBIOSPackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ Begin {
try {
$TSEnvironment = New-Object -ComObject "Microsoft.SMS.TSEnvironment" -ErrorAction Stop
} catch [System.Exception] {
Write-Warning -Message "Unable to construct Microsoft.SMS.TSEnvironment object"; exit
throw "Unable to construct Microsoft.SMS.TSEnvironment object. $($PSItem.Exception.Message)"
}
}

Expand Down Expand Up @@ -689,57 +689,44 @@ Process {
Write-CMLogEntry -Value " - Setting 'AdminServiceURL' variable to: $($Script:AdminServiceURL)" -Severity 1
}

function Install-AuthModule {
# Determine if the PSIntuneAuth module needs to be installed
try {
Write-CMLogEntry -Value " - Attempting to locate PSIntuneAuth module" -Severity 1
$PSIntuneAuthModule = Get-InstalledModule -Name "PSIntuneAuth" -ErrorAction Stop -Verbose:$false
if ($null -ne $PSIntuneAuthModule) {
Write-CMLogEntry -Value " - Authentication module detected, checking for latest version" -Severity 1
$LatestModuleVersion = (Find-Module -Name "PSIntuneAuth" -ErrorAction SilentlyContinue -Verbose:$false).Version
if ($LatestModuleVersion -gt $PSIntuneAuthModule.Version) {
Write-CMLogEntry -Value " - Latest version of PSIntuneAuth module is not installed, attempting to install: $($LatestModuleVersion.ToString())" -Severity 1
$null = Update-Module -Name "PSIntuneAuth" -Scope CurrentUser -Force -ErrorAction Stop -Confirm:$false -Verbose:$false
}
}
} catch [System.Exception] {
Write-CMLogEntry -Value " - Unable to detect PSIntuneAuth module, attempting to install from PSGallery" -Severity 2
try {
# Install NuGet package provider
$null = Install-PackageProvider -Name "NuGet" -Force -Verbose:$false

# Install PSIntuneAuth module
Install-Module -Name "PSIntuneAuth" -Scope AllUsers -Force -ErrorAction Stop -Confirm:$false -Verbose:$false
Write-CMLogEntry -Value " - Successfully installed PSIntuneAuth module" -Severity 1
} catch [System.Exception] {
Write-CMLogEntry -Value " - An error occurred while attempting to install PSIntuneAuth module. Error message: $($_.Exception.Message)" -Severity 3

# Throw terminating error
$ErrorRecord = New-TerminatingErrorRecord -Message ([string]::Empty)
$PSCmdlet.ThrowTerminatingError($ErrorRecord)
}
}
}

function Get-AuthToken {
$TokenRequest = $null
$TokenResponse = $null
try {
# Attempt to install PSIntuneAuth module, if already installed ensure the latest version is being used
Install-AuthModule

# Import MS Intune Auth Token
Write-CMLogEntry -Value " - Importing PSIntuneAuth PS module" -Severity 1
Import-Module -Name PSIntuneAuth

# Retrieve authentication token
# Retrieve OAuth directly so deployment never installs or executes a gallery module as SYSTEM.
Write-CMLogEntry -Value " - Attempting to retrieve authentication token using native client with ID: $($ClientID)" -Severity 1
$Script:AuthToken = Get-MSIntuneAuthToken -TenantName $TenantName -ClientID $ClientID -Credential $Credential -Resource $ApplicationIDURI -RedirectUri "https://login.microsoftonline.com/common/oauth2/nativeclient" -ErrorAction Stop
$TenantIdentifier = [Uri]::EscapeDataString($TenantName.Trim())
$TokenUri = "https://login.microsoftonline.com/$($TenantIdentifier)/oauth2/token"
$TokenRequest = @{
grant_type = "password"
client_id = $ClientID
resource = $ApplicationIDURI
username = $Credential.UserName
password = $Script:Password
}
$TokenResponse = Invoke-RestMethod -Method Post -Uri $TokenUri -Body $TokenRequest -ContentType "application/x-www-form-urlencoded" -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($TokenResponse.token_type) -or [string]::IsNullOrWhiteSpace($TokenResponse.access_token)) {
throw "The Microsoft identity platform response did not contain a token type and access token"
}
$Script:AuthToken = @{
Authorization = "$($TokenResponse.token_type) $($TokenResponse.access_token)"
}
Write-CMLogEntry -Value " - Successfully retrieved authentication token" -Severity 1
} catch [System.Exception] {
Write-CMLogEntry -Value " - Failed to retrieve authentication token. Error message: $($PSItem.Exception.Message)" -Severity 3

# Throw terminating error
$ErrorRecord = New-TerminatingErrorRecord -Message ([string]::Empty)
$PSCmdlet.ThrowTerminatingError($ErrorRecord)
} finally {
if ($null -ne $TokenRequest) {
$TokenRequest.password = $null
}
if ($null -ne $TokenResponse) {
$TokenResponse.access_token = $null
}
$Script:Password = $null
$Script:Credential = $null
}
}

Expand Down Expand Up @@ -1407,7 +1394,8 @@ Process {
}
Write-CMLogEntry -Value "A new version of the BIOS has been detected. Current release $($CurrentBIOSVersion) will be replaced by $($AvailableBIOSVersion)." -Severity 1
}
} elseif ($CurrentBIOSVersion -like "A*") {
} elseif ($CurrentBIOSVersion -match "^A(?<CurrentRevision>\d+)$") {
$CurrentRevision = [int]$Matches.CurrentRevision
# Compare current BIOS release to available
if ($AvailableBIOSVersion -like "*.*.*") {
# Assume that the bios is new as moving from Axx to x.x.x formats
Expand All @@ -1416,7 +1404,8 @@ Process {
$TSEnvironment.Value("NewBIOSAvailable") = $true
}
Write-CMLogEntry -Value "A new version of the BIOS has been detected. Current release $($CurrentBIOSVersion) will be replaced by $($AvailableBIOSVersion)." -Severity 1
} elseif ($AvailableBIOSVersion -gt $CurrentBIOSVersion) {
} elseif ($AvailableBIOSVersion -match "^A(?<AvailableRevision>\d+)$" -and
[int]$Matches.AvailableRevision -gt $CurrentRevision) {
# Write output to task sequence variable
if ($Script:PSCmdlet.ParameterSetName -notlike "Debug") {
$TSEnvironment.Value("NewBIOSAvailable") = $true
Expand Down
6 changes: 4 additions & 2 deletions Invoke-CMDownloadBIOSPackage_Legacy.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ Process {
Write-CMLogEntry -Value "A new version of the BIOS has been detected. Current release $($CurrentBIOSVersion) will be replaced by $($AvailableBIOSVersion)." -Severity 1
}
}
elseif ($CurrentBIOSVersion -like "A*") {
elseif ($CurrentBIOSVersion -match "^A(?<CurrentRevision>\d+)$") {
$CurrentRevision = [int]$Matches.CurrentRevision
# Compare current BIOS release to available
if ($AvailableBIOSVersion -like "*.*.*") {
# Assume that the bios is new as moving from Axx to x.x.x formats
Expand All @@ -309,7 +310,8 @@ Process {
}
Write-CMLogEntry -Value "A new version of the BIOS has been detected. Current release $($CurrentBIOSVersion) will be replaced by $($AvailableBIOSVersion)." -Severity 1
}
elseif ($AvailableBIOSVersion -gt $CurrentBIOSVersion) {
elseif ($AvailableBIOSVersion -match "^A(?<AvailableRevision>\d+)$" -and
[int]$Matches.AvailableRevision -gt $CurrentRevision) {
# Write output to task sequence variable
if ($DebugMode -ne $true) {
$TSEnvironment.Value("NewBIOSAvailable") = $true
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The modern downloader supports two package-selection sources. AdminService mode

BIOS package matching uses the manufacturer, model/SystemSKU and BIOS version or release date, not the Windows release or build number. Windows feature updates do not require a version-mapping entry in these scripts. That includes Windows 11 26H1 (build 28000, a specialized release for selected new hardware) and Windows 11 26H2 (build 26300, the annual enablement-package release). Microsoft Surface firmware delivered through driver packages is still handled as such.

This package-selection independence does not override the deployment platform's support matrix. Windows 11 26H1 is a new-hardware-only release rather than a general upgrade target, and Configuration Manager 2509 does not support Windows 11 26H2 clients; use Configuration Manager 2603 or later for 26H2 task sequences.

The scripts use `Get-CimInstance` for local hardware, ConfigMgr client, time-zone and BitLocker queries. In WinPE, include the WinPE-WMI, WinPE-NetFX, WinPE-Scripting and WinPE-PowerShell optional components and their dependencies. CIM queries are local and do not require WinRM configuration.

Replacing WMI cmdlets does not make the entire solution PowerShell 7 compatible: the legacy downloader still uses `New-WebServiceProxy`, which requires Windows PowerShell. Continue using the supported ConfigMgr task-sequence PowerShell environment.
Expand Down Expand Up @@ -48,6 +50,17 @@ Failure paths now exit with the original Dell exit code instead of a blanket `ex

For a consistent task-sequence contract, HP and Microsoft code 3010 and Lenovo WinUPTP code 0 set `SMSTSBIOSUpdateRebootRequired`. HP code 0 is plain success. Lenovo exit code 1073807364 (`0x3FFF0004`) is not present in Lenovo's published WinUPTP result tables, so the script logs it as an unverified failure and requires review of `winuptp.log` instead of reporting success.

## AdminService authentication security

The modern downloader does not install or update PowerShell Gallery modules during
deployment. External AdminService/CMG authentication uses the configured tenant,
client, application, user name, and password values to request an OAuth token
directly. This resource-owner-password flow is legacy and incompatible with accounts
that require MFA or passwordless authentication. Where the target API supports
app-only access, migrate unattended workloads to a service principal with a
certificate credential; review Microsoft's
[ROPC limitations and migration guidance](https://learn.microsoft.com/entra/identity-platform/v2-oauth-ropc).

## Transport and BitLocker safety

Both downloaders enforce normal HTTPS certificate validation. Configure the ConfigMgr AdminService or legacy web service with a server-authentication certificate that chains to a root trusted by the full operating system and the WinPE boot image. The scripts do not install a global certificate callback or accept an untrusted/self-signed endpoint. Import the issuing CA chain into WinPE when an enterprise PKI is used.
Expand Down
14 changes: 14 additions & 0 deletions Tests/CimMigration.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ try {
Assert-Equal ($TSEnvironment.Values["NewBIOSAvailable"] -eq $true) $Case.Expected "Version comparison in $($File.Name), $($Case.Manufacturer), $($Case.Version)"
}

foreach ($Case in @(
@{ Current = "A9"; Available = "A10"; Expected = $true },
@{ Current = "A10"; Available = "A9"; Expected = $false },
@{ Current = "A10"; Available = "A10"; Expected = $false }
)) {
$script:BIOS.SMBIOSBIOSVersion = $Case.Current
$TSEnvironment.Values.Clear()
Compare-BIOSVersion -ComputerManufacturer "Dell" `
-AvailableBIOSVersion $Case.Available
Assert-Equal ($TSEnvironment.Values["NewBIOSAvailable"] -eq $true) `
$Case.Expected "Dell A-revision comparison in $($File.Name), $($Case.Current) to $($Case.Available)"
}
$script:BIOS.SMBIOSBIOSVersion = "1.2.3"

$script:QueryFails = $true
$Failure = $null
try {
Expand Down
31 changes: 31 additions & 0 deletions Tests/FirmwareSafety.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ $DownloaderText = Get-Content -LiteralPath $DownloaderPath -Raw
foreach ($Text in @($DownloaderText, $LegacyText)) {
Assert-Equal ($Text -match "ServerCertificateValidationCallback|Set-CertificateValidationCallback") $false "Downloaders must not bypass HTTPS certificate validation"
}
Assert-Equal ($DownloaderText -match "(?i)\b(Install|Update)-Module\b") $false "Downloader must not install gallery modules at runtime"
Assert-Equal ($DownloaderText.Contains("Install-AuthModule")) $false "Legacy authentication module bootstrap must be absent"
Assert-Equal ($DownloaderText.Contains('throw "Unable to construct Microsoft.SMS.TSEnvironment object.')) $true "Task-sequence initialization must fail closed"

. ([scriptblock]::Create((Get-FunctionDefinition -ScriptPath $DownloaderPath -Name "Get-AuthToken")))
function Invoke-RestMethod {
param($Method, $Uri, $Body, $ContentType, $ErrorAction)
$script:TokenRequest = [pscustomobject]@{
Method = $Method
Uri = $Uri
Body = $Body.Clone()
ContentType = $ContentType
}
[pscustomobject]@{ token_type = "Bearer"; access_token = "test-token" }
}
$TenantName = "contoso.onmicrosoft.com"
$ClientID = "00000000-0000-0000-0000-000000000001"
$ApplicationIDURI = "https://ConfigMgrService"
$Script:Password = "P@ssw0rd!"
$Credential = New-Object System.Management.Automation.PSCredential(
"svc-bios@contoso.com",
(ConvertTo-SecureString $Script:Password -AsPlainText -Force))
Get-AuthToken
Assert-Equal $script:TokenRequest.Uri "https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/token" "Direct OAuth endpoint"
Assert-Equal $script:TokenRequest.Method "Post" "Direct OAuth method"
Assert-Equal $script:TokenRequest.ContentType "application/x-www-form-urlencoded" "Direct OAuth content type"
Assert-Equal $script:TokenRequest.Body.username "svc-bios@contoso.com" "Direct OAuth user name"
Assert-Equal $script:TokenRequest.Body.password "P@ssw0rd!" "Direct OAuth password"
Assert-Equal $Script:AuthToken.Authorization "Bearer test-token" "Direct OAuth authorization header"
Assert-Equal ($null -eq $Script:Password) $true "Plain-text password is cleared"
Assert-Equal ($null -eq $Script:Credential) $true "Credential is cleared"

foreach ($FileName in @("Invoke-DellBIOSUpdate.ps1", "Invoke-HPBIOSUpdate.ps1", "Invoke-LenovoBIOSUpdate.ps1")) {
$Text = Get-Content -LiteralPath (Join-Path $RepositoryRoot $FileName) -Raw
Expand Down
Loading