2020-05-13

Patching Windows: Part 2

The WU.psm1 module is a work in progress. For a long time I couldn't get it to correctly prioritize that 1903 and 1909 are different versions of Windows, because they share a single KB update. I fixed that yesterday, so I can post it here. (Remember: work in progress. There's a lot of stuff I'd still like to fix, but I want something functional now. Perfect can wait until next month.)

If you really want to know, every monthly Cumulative Update KB article on support.microsoft.com had (emphasis on "had") a format like "(OS build 10.0.12345.678)" that the DeploySharedLibrary looks for in order to fetch and parse its links... but 1903 and 1909 both have two separate KB articles that each have links with a format like "(OS Builds 10.0.12345.678 and 10.0.78901.234)" and the regex just barfed on that.

So I downloaded The Regex Coach and fixed the regex. But fixing the regex also required some changes to the way that the regex matches are handled. The KB number was correct, but it wouldn't display both OS versions, and that bugged me.

This module also maintains a $WindowsOSBuildMap object that will soon include Windows 10 version 2004 when I can find what the support.microsoft.com KB page for that release is. Maybe in June 2020? (Don't expect me to keep this script updated here. Windows is a moving target and I juuuuust got 1909 and 1903 playing well with each other. It's not that hard to update it.)

(Update, 2020-06-09: The KB support ID for Windows 10 version 2004 is 4557957.)

No point in posting the bevy of patches that led to this, you can do the comparison yourself.

(UPDATE, 2022-05-13: Microsoft changed their website to change a dash to a Unicode symbol em-dash and this broke the regex. So I switched the lookup in this module to check Microsoft's Windows 10 RSS feed. The newer version of this module is over on my website as WU.psm1. It is not a drop-in replacement, as I've made a few changes to it to also look up Windows 11 updates if specified.)

# Windows Update module
$VERSION = 0.0 # There is no manifest

# Use with:
#  Import-Module -Force -Name "C:\Path\to\WU.psm1"

# <URL:https://en.wikipedia.org/wiki/Windows_10_version_history>
New-Variable -Option ReadOnly -Name WindowsOSBuildMap -Value @{

  # "Windows 10, versions 1903 and 1909 share a common core operating system and an identical set of system files"
# '10.0.19041' = @{ 'Id' = '???????'; 'Name' = '2004'; EndOfLife = $null; }
  '10.0.18363' = @{ 'Id' = '4529964'; 'Name' = '1909'; EndOfLife = $null; }
  '10.0.18362' = @{ 'Id' = '4498140'; 'Name' = '1903'; EndOfLife = $null; }
  '10.0.17763' = @{ 'Id' = '4464619'; 'Name' = '1809'; EndOfLife = $null; }
  '10.0.17134' = @{ 'Id' = '4099479'; 'Name' = '1803'; EndOfLife = $null; }
  '10.0.16299' = @{ 'Id' = '4043454'; 'Name' = '1709'; EndOfLife = $null; }
  '10.0.15063' = @{ 'Id' = '4018124'; 'Name' = '1703'; EndOfLife = '2019-10-08'; }
  '10.0.14393' = @{ 'Id' = '4000825'; 'Name' = '1607'; EndOfLife = $null; }
  '10.0.10586' = @{ 'Id' = '4000824'; 'Name' = '1511'; EndOfLife = '2018-04-10'; }
  '10.0.10240' = @{ 'Id' = '4000823'; 'Name' = '1507'; EndOfLife = $null; }
}

Function Get-BuildString {
  Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [Version] $Version)
  Return [String]::Join('.', @($Version.Major, $Version.Minor, $Version.Build))
}

Function Get-SecondTuesday {
  Param([Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [DateTime] $DT = (Get-Date))

  $d     = Get-Date -Year $DT.Year -Month $DT.Month -Day 8
  $num   = [UInt16] $d.DayOfWeek # 0 = Sun, 1 = Mon, 2 = Tue, etc.
  $delta = (9 - $num) % 7 # 9 = 7 + 2 (2 = Tue)
  $d     = $d.AddDays($delta)
  Return $d
}

Function IsDateAfterSecondTuesday {
  Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [DateTime] $DT)

  $d = Get-SecondTuesday -DT $DT
  Return (0 -lt [DateTime]::Compare($DT, $d))
}

$cal   = New-Object System.Globalization.GregorianCalendar
$ascii = New-Object System.Text.ASCIIEncoding

Function Get-CurrentKBUpdateList {
  Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [Version] $Build)

  $entries    = @()
  $build_str  = Get-BuildString -Version $build
  $build_maj  = [String]::Join('.', @($build.Major, $build.Minor, ''))
  $uri        = "https://support.microsoft.com/en-us/help/{0}" -f ($WindowsOSBuildMap[$build_str].Id)
  $wr         = Invoke-WebRequest -Uri $uri
  $matches    = Select-String -InputObject $wr.Content -Pattern '"([^\-\"]*)\p{Pd}(KB[0-9]*) \(OS Build(s)? ([0-9\.]*)( and ([0-9\.]*))?.+"' -AllMatches

  foreach ($match in $matches.Matches) {
    $dt             = [DateTime]$match.Groups[1].Value
    $second_tuesday = Get-SecondTuesday -DT $dt
    $week_b         = $cal.GetWeekOfYear($second_tuesday, [System.Globalization.CalendarWeekRule]::FirstDay, [System.DayOfWeek]::Sunday)
    $week_num       = $cal.GetWeekOfYear($dt,             [System.Globalization.CalendarWeekRule]::FirstDay, [System.DayOfWeek]::Sunday)

    @(6,4) | foreach {
      if (-not ([String]::IsNullOrEmpty($match.Groups[$_].Value))) {
        $version     = [Version]($build_maj + $match.Groups[$_].Value)
        $version_key = Get-BuildString -Version $version

        if ($WindowsOSBuildMap[$version_key].Name -eq $WindowsOSBuildMap[$build_str].Name) {
          $entries += New-Object -TypeName PSObject -Property @{
            'KB'          = $match.Groups[2].Value;
            'OSBuild'     = $version;
            'OSName'      = $WindowsOSBuildMap[$build_str].Name;
            'ReleaseDate' = $dt.ToString('yyyy-MM-dd');
            'ReleaseWeek' = $ascii.GetChars(66 + ($week_num - $week_b))[0];
            'SupportId'   = $WindowsOSBuildMap[$build_str].Id;
          }
        }
      }
    }
  }

  Return (Sort-Object -InputObject $entries -Property ReleaseDate -Descending) | Select-Object -First 1
}

Function Get-PublishedKBUpdates {
  Param()

  $updates = @()
  foreach ($build in ($WindowsOSBuildMap.Keys | Sort-Object -Descending)) {
    $updates += Get-CurrentKBUpdateList -Build $build
  }
  Return $updates
}

Function Get-KBSource {
  Param(
    [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $KB,
    [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $Filter
  )

  $uri_search   = "http://www.catalog.update.microsoft.com/Search.aspx?q={0}" -f ($KB)
  $uri_download = 'http://www.catalog.update.microsoft.com/DownloadDialog.aspx'
  $wr           = Invoke-WebRequest -Uri $uri_search

  Set-StrictMode -Version 1 # some of these things won't exist
    $ids = $wr.InputFields | Where-Object { $_.type -eq 'Button' -and $_.Value -eq 'Download' } | Select-Object -ExpandProperty ID

    $guids = $wr.Links | 
      Where-Object ID -match '_link' |
      Where-Object { $_.OuterHTML -match $Filter } |
      ForEach-Object { $_.id.replace('_link','') } |
      Where-Object { $_ -in $ids }
  Set-StrictMode -Version 2

  $links = @()
  foreach ( $guid in $guids ) {
    $Post = @{ size = 0; updateID = $guid; uidInfo = $guid } | ConvertTo-Json -Compress
    $postBody = @{ updateIDs = "[$Post]" } 
    $links += Invoke-WebRequest -Uri $uri_download -Method POST -Body $postBody |
      Select-Object -ExpandProperty Content |
      Select-String -AllMatches -Pattern "(http[s]?\://download\.windowsupdate\.com\/[^\'\""]*)" | 
      ForEach-Object { $_.matches.value }
  }

  Return ($links | Select-Object -Unique | ForEach-Object { [PSCustomObject] @{ Source = $_ } })
}

Export-ModuleMember -Variable WindowsOSBuildMap

Export-ModuleMember -Function Get-BuildString
Export-ModuleMember -Function Get-SecondTuesday
Export-ModuleMember -Function IsDateAfterSecondTuesday
Export-ModuleMember -Function Get-CurrentKBUpdateList
Export-ModuleMember -Function Get-PublishedKBUpdates
Export-ModuleMember -Function Get-KBSource

# END

Next time: How do use this darn thing.

No comments: