How to perform Windows updates on a DMZ machine with no internet.
mkdir C:\DMZAudit
:: Installed updates (CSV)
wmic qfe list full /format:csv > C:\DMZAudit\hotfixes.csv
:: DISM packages - full, non-tabular, English (includes identities)
dism /online /get-packages /English > C:\DMZAudit\dism_packages_full.txt
dism /online /get-packages /format:table /English > C:\DMZAudit\dism_packages_table.txt
:: OS info
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ProductName > C:\DMZAudit\os_info.txt
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v CurrentBuild >> C:\DMZAudit\os_info.txt
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v UBR >> C:\DMZAudit\os_info.txt
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v BuildLabEx >> C:\DMZAudit\os_info.txt
:: SSU/Rollup hints
dism /online /get-packages /English | findstr /i "ServicingStack SSU Package_for_RollupFix" > C:\DMZAudit\ssu_rollup_hint.txt
:: (Optional) Component Based Servicing registry (helps spot SSU)
reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\Packages" /s > C:\DMZAudit\cbs_packages.txt
Exit codes:
0 = Up to date
1 = Updates needed
2 = Could not auto-detect latest CU/SSU/UBR (supply overrides)
USAGE:
.\Audit.ps1 -Folder “C:\DMZAudit” [-OutCsv path] [-OutHtml path]
.\Audit.ps1 -Folder “C:\DMZAudit” -OverrideCUKB 5065429 -OverrideSSUKB 5031539 -OverrideLatestUBR 6332
<#
Audit.ps1 (PowerShell 5.1 compatible)
Supports:
- Windows Server: 2016 (14393), 2019 (17763), 2022 (20348), 2025 (26100)
- Windows 10: 2004/20H2/21H1/21H2/22H2 (19041–19045)
Inputs: (copied from the DMZ machine, gathered without scripts)
- hotfixes.csv (wmic qfe list full /format:csv)
- dism_packages_full.txt (dism /online /get-packages /English)
- dism_packages.txt OR dism_packages_table.txt (fallback)
- os_info.txt (reg query ProductName, CurrentBuild, UBR, BuildLabEx)
- ssu_rollup_hint.txt (optional; findstr of "ServicingStack SSU Package_for_RollupFix")
- cbs_packages.txt (optional; Component Based Servicing registry export)
Outputs:
- Console summary + EXACT KBs needed (SSU first, then CU) with Catalog links
- Optional CSV/HTML via -OutCsv / -OutHtml
Exit codes:
0 = Up to date
1 = Updates needed
2 = Could not auto-detect latest CU/SSU/UBR (supply overrides)
USAGE:
.\Audit.ps1 -Folder "C:\DMZAudit" [-OutCsv path] [-OutHtml path]
.\Audit.ps1 -Folder "C:\DMZAudit" -ExpectKB 5005627,5005112 -ExpectAtLeastUBR 5241
.\Audit.ps1 -Folder "C:\DMZAudit" -OverrideCUKB 5065429 -OverrideSSUKB 5031539 -OverrideLatestUBR 6332
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Folder,
[string]$OutCsv,
[string]$OutHtml,
# Manual overrides if auto-detection fails:
[int]$OverrideCUKB,
[int]$OverrideSSUKB,
[int]$OverrideLatestUBR,
# Optional operator expectations:
[int[]]$ExpectKB,
[int]$ExpectAtLeastUBR
)
# ========================== Helpers ========================================
function Parse-UBR {
param([string]$text)
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
foreach ($line in $text -split "`r?`n") {
if ($line -match "\sUBR\s+REG_DWORD\s+0x([0-9a-fA-F]+)") {
return [int]("0x" + $Matches[1])
}
}
return $null
}
function Parse-CurrentBuild {
param([string]$text)
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
foreach ($line in $text -split "`r?`n") {
if ($line -match "\sCurrentBuild\s+REG_SZ\s+(\d+)") {
return [int]$Matches[1]
}
}
return $null
}
function Parse-ProductName {
param([string]$text)
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
foreach ($line in $text -split "`r?`n") {
if ($line -match "\sProductName\s+REG_SZ\s+(.+)$") {
return $Matches[1].Trim()
}
}
return $null
}
function Parse-BuildLabExUBR {
param([string]$text)
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
foreach ($line in $text -split "`r?`n") {
if ($line -match "\sBuildLabEx\s+REG_SZ\s+(\d+)\.(\d+)\.") {
# e.g., 14393.5241.amd64fre...
return [int]$Matches[2]
}
}
return $null
}
function Get-InstalledKBsFromWMIC {
param([string]$csvPath)
if (-not (Test-Path $csvPath)) { return @() }
# Try Import-Csv; if headers are odd, fallback to raw regex.
try {
$rows = Import-Csv $csvPath -ErrorAction Stop
$kbs = @()
foreach ($r in $rows) {
foreach ($p in $r.PSObject.Properties) {
if ($p.Name -match "HotFixID" -and $p.Value -match "KB(\d{5,7})") {
$kbs += [int]$Matches[1]
}
}
}
if ($kbs.Count -gt 0) { return ($kbs | Sort-Object -Unique) }
} catch { }
$raw = Get-Content $csvPath -Raw
if ([string]::IsNullOrEmpty($raw)) { return @() }
$matches = [regex]::Matches($raw, "KB(\d{5,7})")
if ($matches.Count -eq 0) { return @() }
$nums = @()
foreach ($m in $matches) { $nums += [int]$m.Groups[1].Value }
return ($nums | Sort-Object -Unique)
}
function Get-InstalledUBRFromDISM {
param([string[]]$paths, [int]$buildMajor)
foreach ($p in $paths) {
if (-not (Test-Path $p)) { continue }
$txt = Get-Content $p -Raw
if ([string]::IsNullOrEmpty($txt)) { continue }
$m = [regex]::Matches($txt, "Package_for_RollupFix~.*?$buildMajor\.(\d+)")
if ($m.Count) {
$ubrs = @()
foreach ($x in $m) { $ubrs += [int]$x.Groups[1].Value }
return ($ubrs | Sort-Object -Descending | Select-Object -First 1)
}
}
return $null
}
function Get-InstalledSSUHint {
param([string]$txtPath)
if (-not (Test-Path $txtPath)) { return @() }
$txt = Get-Content $txtPath -Raw
if ([string]::IsNullOrEmpty($txt)) { return @() }
$m = [regex]::Matches($txt, "KB(\d{5,7})")
if ($m.Count) {
$kbs = @()
foreach ($x in $m) { $kbs += [int]$x.Groups[1].Value }
return ($kbs | Sort-Object -Unique)
}
return @()
}
# Build -> OS line mapping & update-history URL
function Resolve-OSLine {
param([int]$build, [string]$productName)
# Windows Server
switch ($build) {
14393 { return [pscustomobject]@{ Name='Windows Server 2016'; Build=$build; Url='https://support.microsoft.com/help/4000825' } }
17763 { return [pscustomobject]@{ Name='Windows Server 2019'; Build=$build; Url='https://support.microsoft.com/topic/windows-10-and-windows-server-2019-update-history-725fc2e1-4443-6831-a5ca-51ff5cbcb059' } }
20348 { return [pscustomobject]@{ Name='Windows Server 2022'; Build=$build; Url='https://support.microsoft.com/topic/windows-server-2022-update-history-e1caa597-00c5-4ab9-9f3e-8212fe80b2ee' } }
26100 { return [pscustomobject]@{ Name='Windows Server 2025'; Build=$build; Url='https://support.microsoft.com/topic/windows-server-2025-update-history-10f58da7-e57b-4a9d-9c16-9f1dcd72d7d7' } }
}
# Windows 10 (19041–19045)
switch ($build) {
{$_ -in 19041,19042,19043,19044,19045} {
$name =
switch ($build) {
19041 { 'Windows 10, version 2004' }
19042 { 'Windows 10, version 20H2' }
19043 { 'Windows 10, version 21H1' }
19044 { 'Windows 10, version 21H2' }
19045 { 'Windows 10, version 22H2' }
default { "Windows 10 (build $build)" }
}
$url = 'https://support.microsoft.com/windows/windows-10-update-history-7a81b039-33ad-3ae1-2a22-3b8b8e5b11aa'
return [pscustomobject]@{ Name=$name; Build=$build; Url=$url }
}
}
# Fallback: if ProductName includes "Windows 10", still use the hub page
if ($productName -and $productName -like '*Windows 10*') {
return [pscustomobject]@{
Name = "Windows 10 (build $build)"
Build = $build
Url = 'https://support.microsoft.com/windows/windows-10-update-history-7a81b039-33ad-3ae1-2a22-3b8b8e5b11aa'
}
}
return $null
}
# Scrape latest CU/SSU KBs and highest UBR from Microsoft page
function Get-LatestOSUpdateInfo {
param($osLine)
if (-not $osLine) { return $null }
try {
$resp = Invoke-WebRequest -UseBasicParsing -Uri $osLine.Url -TimeoutSec 45
$html = $resp.Content
} catch {
return $null
}
# Highest observed UBR for this build
$latestUBR = $null
if ($osLine.Build) {
$ubrMatches = [regex]::Matches($html, "$($osLine.Build)\.(\d{3,6})")
if ($ubrMatches.Count) {
$ubrs = @()
foreach ($m in $ubrMatches) { $ubrs += [int]$m.Groups[1].Value }
$latestUBR = ($ubrs | Sort-Object -Descending | Select-Object -First 1)
}
}
# Heuristics for CU/SSU KBs (works across Server pages; Win10 hub needs a fallback)
$cuKB = $null
$ssuKB = $null
$cuMatches = [regex]::Matches($html, "(?i)Cumulative Update.*?(KB\d{5,7})")
if ($cuMatches.Count) { $cuKB = ($cuMatches | Select-Object -First 1).Groups[1].Value.Substring(2) }
$ssuMatches = [regex]::Matches($html, "(?i)Servicing Stack Update.*?(KB\d{5,7})")
if ($ssuMatches.Count) { $ssuKB = ($ssuMatches | Select-Object -First 1).Groups[1].Value.Substring(2) }
# Win10 hub fallback: find a KB tied to the current build line (19041–19045)
if (-not $cuKB -and ($osLine.Build -ge 19041 -and $osLine.Build -le 19045)) {
$kbBuildMatches = [regex]::Matches($html, "KB(\d{5,7}).{0,200}?$($osLine.Build)\.\d{3,6}", 'IgnoreCase')
if ($kbBuildMatches.Count) { $cuKB = ($kbBuildMatches | Select-Object -First 1).Groups[1].Value }
}
# If SSU not obvious for Win10 2004–22H2, default to widely required SSU KB5031539 (2023-10)
if (-not $ssuKB -and ($osLine.Build -ge 19041 -and $osLine.Build -le 19045)) {
$ssuKB = '5031539'
}
return [pscustomobject]@{
LatestCU_KB = if ($cuKB) { [int]$cuKB } else { $null }
LatestSSU_KB = if ($ssuKB) { [int]$ssuKB } else { $null }
LatestUBR = $latestUBR
SourceUrl = $osLine.Url
}
}
function New-CatalogUrl { param([int]$kb) "https://www.catalog.update.microsoft.com/Search.aspx?q=KB$kb" }
function Export-NeededUpdates {
param($list, $OutCsv, $OutHtml)
if ($OutCsv) {
$list | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $OutCsv
Write-Host "Needed updates CSV: $OutCsv"
}
if ($OutHtml) {
Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue | Out-Null
$rowsSb = New-Object System.Text.StringBuilder
foreach ($item in $list) {
$reasonHtml = [System.Web.HttpUtility]::HtmlEncode($item.Reason)
[void]$rowsSb.AppendLine("<tr><td>KB$($item.KB)</td><td>$($item.Type)</td><td>$reasonHtml</td><td><a href='$($item.CatalogSearchUrl)'>Catalog</a></td><td>$($item.InstallOrder)</td></tr>")
}
$html = @"
<html><head><meta charset='utf-8'><title>Needed Updates</title>
<style>
body{font-family:Segoe UI,Arial,sans-serif;margin:20px}
table{border-collapse:collapse}
th,td{border:1px solid #ddd;padding:6px}
th{background:#f2f2f2}
</style>
</head><body>
<h2>Needed Updates</h2>
<table><tr><th>KB</th><th>Type</th><th>Reason</th><th>Catalog</th><th>Install Order</th></tr>
$rowsSb
</table>
<p style='color:#666'>Generated: $(Get-Date)</p>
</body></html>
"@
$html | Out-File -Encoding UTF8 $OutHtml
Write-Host "Needed updates HTML: $OutHtml"
}
}
# ============================ MAIN =========================================
# Prefer full DISM text; fallback to table
$dismFull = Join-Path $Folder "dism_packages_full.txt"
$dismTbl = Join-Path $Folder "dism_packages.txt"
if (-not (Test-Path $dismTbl)) {
$dismTbl = Join-Path $Folder "dism_packages_table.txt"
}
$hotfixCsv = Join-Path $Folder "hotfixes.csv"
$osInfoTxt = Join-Path $Folder "os_info.txt"
$ssuHintTxt = Join-Path $Folder "ssu_rollup_hint.txt"
$cbsTxt = Join-Path $Folder "cbs_packages.txt"
if (-not (Test-Path $hotfixCsv)) { throw "Missing $hotfixCsv" }
if (-not (Test-Path $osInfoTxt)) { throw "Missing $osInfoTxt" }
$osInfo = Get-Content $osInfoTxt -Raw
$regUBR = Parse-UBR $osInfo
$regBuild = Parse-CurrentBuild $osInfo
$productName = Parse-ProductName $osInfo
$labUBR = Parse-BuildLabExUBR $osInfo # extra fallback
$osLine = Resolve-OSLine -build $regBuild -productName $productName
$installedKBs = Get-InstalledKBsFromWMIC $hotfixCsv
$dismUBR = Get-InstalledUBRFromDISM @($dismFull,$dismTbl) -buildMajor $regBuild
$ssuHints = if (Test-Path $ssuHintTxt) { Get-InstalledSSUHint $ssuHintTxt } else { @() }
# Choose best UBR we can derive
$currentUBR = $dismUBR
if (-not $currentUBR -and $regUBR) { $currentUBR = $regUBR }
if (-not $currentUBR -and $labUBR) { $currentUBR = $labUBR }
# Output context
$osName = if ($osLine -and $osLine.Name) { $osLine.Name } else { 'Unknown' }
Write-Host "Detected OS : $osName" -ForegroundColor Cyan
Write-Host "Detected Build : $regBuild" -ForegroundColor Cyan
Write-Host "Detected UBR : $currentUBR (registry=$regUBR; dism=$dismUBR; buildlab=$labUBR)" -ForegroundColor Cyan
Write-Host "Installed KBs : $(@($installedKBs | ForEach-Object { 'KB' + $_ }) -join ', ')" -ForegroundColor Gray
if ($ssuHints.Count) { Write-Host "SSU hints : $(@($ssuHints | ForEach-Object { 'KB' + $_ }) -join ', ')" -ForegroundColor Gray }
# Get "latest" CU/SSU/UBR (auto or manual override)
$latest = $null
if ($osLine) { $latest = Get-LatestOSUpdateInfo -osLine $osLine }
if (-not $latest -and ($OverrideCUKB -or $OverrideSSUKB -or $OverrideLatestUBR)) {
$latest = [pscustomobject]@{
LatestCU_KB = if ($OverrideCUKB) { [int]$OverrideCUKB } else { $null }
LatestSSU_KB = if ($OverrideSSUKB) { [int]$OverrideSSUKB } else { $null }
LatestUBR = if ($OverrideLatestUBR) { [int]$OverrideLatestUBR } else { $null }
SourceUrl = $null
}
}
if (-not $latest) {
Write-Warning "Could not auto-detect latest CU/SSU/UBR for build $regBuild. Use -OverrideCUKB / -OverrideSSUKB / -OverrideLatestUBR."
exit 2
}
Write-Host ("Latest CU KB : {0}" -f ($(if ($latest.LatestCU_KB) { 'KB' + $latest.LatestCU_KB } else { '(unknown)' }))) -ForegroundColor Green
Write-Host ("Latest SSU KB : {0}" -f ($(if ($latest.LatestSSU_KB) { 'KB' + $latest.LatestSSU_KB } else { '(unknown)' }))) -ForegroundColor Green
Write-Host ("Latest UBR : {0}" -f ($(if ($latest.LatestUBR) { $latest.LatestUBR } else { '(unknown)' }))) -ForegroundColor Green
if ($latest.SourceUrl) { Write-Host ("Source : {0}" -f $latest.SourceUrl) -ForegroundColor DarkGray }
# Decide what's needed
$needed = New-Object System.Collections.Generic.List[object]
$hasLatestCU = $latest.LatestCU_KB -and ($installedKBs -contains [int]$latest.LatestCU_KB)
$hasLatestSSU = $latest.LatestSSU_KB -and ( ($installedKBs -contains [int]$latest.LatestSSU_KB) -or ($ssuHints -contains [int]$latest.LatestSSU_KB) )
$ubrOK = $latest.LatestUBR -and ($currentUBR -ge [int]$latest.LatestUBR)
if ($latest.LatestSSU_KB -and -not $hasLatestSSU) {
$needed.Add([pscustomobject]@{
KB = [int]$latest.LatestSSU_KB
Type = 'SSU'
Reason = 'Required servicing stack for latest LCU; install first.'
CatalogSearchUrl = New-CatalogUrl $latest.LatestSSU_KB
InstallOrder = 1
})
}
if ($latest.LatestCU_KB -and -not $hasLatestCU) {
$reason = if ($ubrOK) { 'Latest CU KB not detected in installed HotFix list.' } else { "Update to build $regBuild.$($latest.LatestUBR) or higher." }
$needed.Add([pscustomobject]@{
KB = [int]$latest.LatestCU_KB
Type = 'CU'
Reason = $reason
CatalogSearchUrl = New-CatalogUrl $latest.LatestCU_KB
InstallOrder = 2
})
}
# Edge: CU looks installed but UBR below expected => suggest reapplying CU
if ($needed.Count -eq 0 -and -not $ubrOK -and $latest.LatestUBR -and $latest.LatestCU_KB) {
$needed.Add([pscustomobject]@{
KB = [int]$latest.LatestCU_KB
Type = 'CU'
Reason = "Installed UBR ($currentUBR) is below latest ($($latest.LatestUBR)); reapply CU."
CatalogSearchUrl = New-CatalogUrl $latest.LatestCU_KB
InstallOrder = 2
})
}
# Operator expectations (force checks)
if ($ExpectAtLeastUBR -and $currentUBR -lt $ExpectAtLeastUBR -and $latest.LatestCU_KB) {
$needed.Add([pscustomobject]@{
KB = [int]$latest.LatestCU_KB
Type = 'CU'
Reason = "Policy: require at least UBR $ExpectAtLeastUBR (current $currentUBR)."
CatalogSearchUrl = New-CatalogUrl $latest.LatestCU_KB
InstallOrder = 2
})
}
if ($ExpectKB) {
foreach ($ekb in $ExpectKB) {
if ($installedKBs -notcontains [int]$ekb -and ($needed | Where-Object KB -EQ $ekb).Count -eq 0) {
$needed.Add([pscustomobject]@{
KB = [int]$ekb
Type = '(Required)'
Reason = 'Expected KB not present (per operator).'
CatalogSearchUrl = New-CatalogUrl $ekb
InstallOrder = 1
})
}
}
}
if ($needed.Count -eq 0) {
$statusName = if ($osLine -and $osLine.Name) { $osLine.Name } else { "this build" }
Write-Host "`nSTATUS: Up to date for $statusName." -ForegroundColor Green
exit 0
}
$needed = $needed | Sort-Object InstallOrder, KB
Write-Host "`nNeeded Updates:" -ForegroundColor Red
$needed | Format-Table @{n='KB';e={'KB' + $_.KB}}, Type, Reason, CatalogSearchUrl, InstallOrder -AutoSize
Export-NeededUpdates -list $needed -OutCsv $OutCsv -OutHtml $OutHtml
Write-Host "`nInstall order: Apply SSU (if listed) first, then CU. Reboot if prompted." -ForegroundColor Yellow
Write-Host "Use the Catalog links for the x64 package matching your OS/version." -ForegroundColor Yellow
exit 1