Skip to content
Merged
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
34 changes: 25 additions & 9 deletions Get-Win11CumulativeUpdates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ function Invoke-CatalogRequest {
[Parameter(Mandatory)][hashtable]$Params,
[int]$MaxAttempts = 3
)
# Bounded timeout + browser UA so a stalled catalog connection can't hang the run
# (callers can override TimeoutSec, e.g. the large .msu download).
if (-not $Params.ContainsKey('TimeoutSec')) { $Params['TimeoutSec'] = 60 }
if (-not $Params.ContainsKey('UserAgent')) { $Params['UserAgent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) lan-ipxe-catalog-scrape' }
Comment on lines +45 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

PowerShell's Invoke-WebRequest renders a progress bar by default, which can severely degrade download performance (often by 10x or more) and cause high CPU usage, especially when downloading large files like the cumulative update .msu packages (which can be 1.5 GB or more).

Setting $ProgressPreference = 'SilentlyContinue' locally within Invoke-CatalogRequest will suppress the progress bar and significantly speed up the downloads without polluting the caller's scope.

    # Bounded timeout + browser UA so a stalled catalog connection can't hang the run
    # (callers can override TimeoutSec, e.g. the large .msu download).
    $ProgressPreference = 'SilentlyContinue'
    if (-not $Params.ContainsKey('TimeoutSec')) { $Params['TimeoutSec'] = 60 }
    if (-not $Params.ContainsKey('UserAgent')) { $Params['UserAgent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) lan-ipxe-catalog-scrape' }

for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
return Invoke-WebRequest @Params
Expand Down Expand Up @@ -98,7 +102,7 @@ function Find-CatalogUpdates {
# Inline retry-with-backoff (the script-scope helper is not visible here).
$DetailsPage = $null
for ($attempt = 1; $attempt -le 3; $attempt++) {
try { $DetailsPage = Invoke-WebRequest -Uri $DetailsUrl -UseBasicParsing; break }
try { $DetailsPage = Invoke-WebRequest -Uri $DetailsUrl -UseBasicParsing -TimeoutSec 60 -UserAgent 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) lan-ipxe-catalog-scrape'; break }
catch { if ($attempt -ge 3) { throw } else { Start-Sleep -Seconds ($attempt * 2) } }
}
$Content = $DetailsPage.Content
Expand All @@ -107,7 +111,10 @@ function Find-CatalogUpdates {
$DateString = if ($Content -match 'id="ScopedViewHandler_date">([^<]+)') { $matches[1].Trim() }

if ($Title -and $DateString) {
$DateObj = [datetime]::Parse($DateString)
# Locale-independent parse: the catalog serves US M/d/yyyy. Plain [datetime]::Parse
# is CurrentCulture-bound, so on a non-US host a day>12 throws (caught below -> the
# CU is silently dropped, possibly the newest) and day<=12 silently swaps day/month.
$DateObj = [datetime]::ParseExact($DateString, [string[]]@('M/d/yyyy', 'MM/dd/yyyy'), [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure robustness and consistency with the shared engine (CatalogScrape.psm1), consider including the same set of date formats when parsing. The shared engine uses:
@('M/d/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd', 'M/d/yyyy h:mm:ss tt')

This prevents potential parsing failures if the catalog format ever changes or includes a time component.

                $DateObj = [datetime]::ParseExact($DateString, [string[]]@('M/d/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd', 'M/d/yyyy h:mm:ss tt'), [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None)

[PSCustomObject]@{
Title = $Title
DateObj = $DateObj
Expand Down Expand Up @@ -156,8 +163,8 @@ function Get-CatalogPackage {

# FIX: the DownloadDialog can list MULTIPLE packages for one update ID. For
# checkpoint-era CUs (24H2+) it returns the ENTIRE required checkpoint chain
# (several .msu), and the actual target is NOT reliably the last link (Microsoft
# currently puts a checkpoint last). Download ALL of them into one folder so DISM
# (several .msu), and the actual target is NOT reliably the last link (the checkpoint's
# position varies — observed FIRST for 25H2, LAST for 24H2). Download ALL into one folder so DISM
# can resolve the dependency chain automatically, and identify the target by the
# KB number rather than by list position.
$AllUrls = @([regex]::Matches($DownloadPage.Content, 'https://[^''"<]+\.(msu|cab)') |
Expand All @@ -173,7 +180,9 @@ function Get-CatalogPackage {
$FileName = ($DownloadUrl -split '/')[-1]
$OutFile = Join-Path $DestinationPath $FileName
$HashFile = "$OutFile.sha256"
$IsTarget = [bool]($TargetKB -and ($FileName -match "(?i)kb$TargetKB"))
# Anchor on the surrounding boundary so a shorter KB can't prefix-match a longer one
# (e.g. KB5012 vs KB50123); catalog filenames are windows11.0-kb<num>-x64_...
$IsTarget = [bool]($TargetKB -and ($FileName -match "(?i)kb$([regex]::Escape($TargetKB))(?:\D|$)"))

# NOTE: the SHA256 sidecar below is a LOCAL CACHE / CORRUPTION check only. It
# confirms the file still matches what we previously downloaded (detects
Expand All @@ -186,6 +195,13 @@ function Get-CatalogPackage {
$actualHash = (Get-FileHash -Path $OutFile -Algorithm SHA256).Hash
if ($actualHash -eq $expectedHash) {
Write-Host " -> Cached (SHA256 cache check OK): $FileName" -ForegroundColor DarkGray
# Re-verify authenticity on cache reuse too: the sidecar is self-generated
# (byte-stability only), so it must NOT bypass the Authenticode check that the
# fresh-download path performs.
$cachedSig = Get-AuthenticodeSignature -FilePath $OutFile
if ($cachedSig.Status -ne 'Valid') {
Write-Warning " [!] Authenticode signature for cached $FileName is '$($cachedSig.Status)' (not Valid) — treat this package as UNVERIFIED before servicing."
}
Comment on lines +201 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Get-AuthenticodeSignature is a Windows-only cmdlet and is not available in PowerShell Core on Linux or macOS. Since this script is used in a LAN iPXE environment (as indicated by the user agent lan-ipxe-catalog-scrape), it is highly likely to be run on Linux-based pre-staging or PXE servers.

To prevent the script from throwing a CommandNotFoundException and failing on non-Windows platforms, guard the Get-AuthenticodeSignature calls with the $IsWindows automatic variable.

Note: You should also apply this guard to the fresh-download path's signature check (around line 235), which is outside this diff hunk.

                    if ($IsWindows) {
                        $cachedSig = Get-AuthenticodeSignature -FilePath $OutFile
                        if ($cachedSig.Status -ne 'Valid') {
                            Write-Warning "   [!] Authenticode signature for cached $FileName is '$($cachedSig.Status)' (not Valid) — treat this package as UNVERIFIED before servicing." 
                        }
                    }

$Downloaded += [PSCustomObject]@{ Path = $OutFile; FileName = $FileName; IsTarget = $IsTarget }
continue
}
Expand All @@ -204,7 +220,8 @@ function Get-CatalogPackage {

Write-Host " -> Downloading $FileName..."
try {
Invoke-CatalogRequest -Params @{ Uri = $DownloadUrl; OutFile = $OutFile; UseBasicParsing = $true } | Out-Null
# Large .msu (often 600 MB - 1.5 GB) — generous timeout so a slow link doesn't abort.
Invoke-CatalogRequest -Params @{ Uri = $DownloadUrl; OutFile = $OutFile; UseBasicParsing = $true; TimeoutSec = 3600 } | Out-Null
}
catch {
Write-Warning " [!] Download failed for $FileName : $_"
Expand Down Expand Up @@ -291,7 +308,6 @@ else {
Write-Host " -> Downloading target CU and any bundled checkpoint prerequisites..." -ForegroundColor Cyan

$DownloadedPackages = @(Get-CatalogPackage -Update $TargetCU -DestinationPath $DownloadPath -TargetKB $TargetKB)
$downloadedCount = $DownloadedPackages.Count

if ($DownloadedPackages.Count -gt 0) {
foreach ($f in $DownloadedPackages) {
Expand Down Expand Up @@ -323,8 +339,8 @@ Write-Host " Done."
if ($SSUResults) {
$BestSSU = $SSUResults | Sort-Object DateObj -Descending | Select-Object -First 1
Write-Host " -> SSU Found: $($BestSSU.Title) ($($BestSSU.DateObj.ToString('yyyy-MM-dd')))" -ForegroundColor Green
$ssuFiles = @(Get-CatalogPackage -Update $BestSSU -DestinationPath $DownloadPath)
if ($ssuFiles.Count -gt 0) { $downloadedCount += $ssuFiles.Count }
# Download the SSU into the same folder (the final summary recounts from disk).
Get-CatalogPackage -Update $BestSSU -DestinationPath $DownloadPath | Out-Null
}
else {
Write-Host " -> No standalone SSU found (likely integrated into CU packages)." -ForegroundColor DarkGray
Expand Down
Loading