October 19, 2025
11 11 11 AM

DMZ Machine Window Updates

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

Leave a Reply

Your email address will not be published. Required fields are marked *