Create a Catalog of Windows Update Data using Microsoft Graph

For some time I have run my own reporting solution for Windows Updates since I’ve never really been happy with the canned reports Microsoft have created in Intune and Windows Update for Business reports, even to this day. As part of this solution I have had to gather data on Windows Updates, such as versions, build numbers, editions, release history, support periods, error codes etc. When the solution was first created there was no public API available to get this information from, so I simply scraped Microsoft web pages to get the data. Recently though, I remembered that Microsoft introduced the Windows Updates API in Microsoft Graph which contains some of this data available through a ‘supported’ API. (I say ‘supported’ in quotes since the API is still beta and subject to change, and currently is actually incomplete.)

After playing around with the available data in the API, I came up with a script to extract the data into a number of lists or tables, which then forms a sort of catalog of data on Windows Updates. You could run some automation to regularly import this into a SQL database or some other data repository and reference it in your own reporting.

The script creates 5 lists or tables (data tables are easier to work with when importing into SQL database). Note – I have intentionally excluded updates for Windows Server OS from the lists as the focus is on workstation / client OS.

Catalog table

Stored in the variables $CatalogList and $CatalogTable. Contains a list of security and non-security updates and their release dates.

Revision Table

Stored in the variable $RevisionList and $RevisionTable. Contains a list of Windows updates, the Windows versions they apply to, release dates, build numbers, KB number and Urls. This table can be ‘joined’ to the catalog table using the catalogUpdateId column, ie catalog.id > revision.catalogUpdateId.

This table only appears to provide data for in-support OS versions though.

Servicing Periods Table

Stored in the variables $ServicingPeriodsList and $ServicingPeriodTables. Contains a list of Windows editions and the start and end dates of the servicing periods for those editions.

Editions Table

Stored in the variables $EditionsList and $EditionsTable. Contains a list of Windows versions and editions.

Known Issues Table

Stored in the variables $KnownIssueList and $KnownIssueTable. Contains similar info available in the release health pages for Windows versions, such as Windows 11, version 24H2 known issues and notifications, with issue descriptions, details, latest update date, issue status, KB, applicable versions etc.

Interestingly, although this data is undoubtedly useful, for my own reporting solution I decided to continue using data scraped from MS web pages since I found it more complete and also significantly quicker to obtain the information.

Having an API to obtain this data though is a great improvement from MS and a step in the right direction at least. If Microsoft could include data for out-of-support versions in the API as well, this could be icing on the cake.

Here’s the script I used:

## ###########################################################################################
## Azure Automation Runbook to retrieve Windows Update Catalog entries from Microsoft Graph ##
##############################################################################################
#region ————————————- Permissions ————————————-
# This runbook requires the following permissions:
# Delegated permissions:
# – WindowsUpdates.ReadWrite.All
# – Member of the'Intune Administrator' or 'Windows Update Deployment Administrator' Entra role
# Application permissions:
# – WindowsUpdates.ReadWrite.All
#endregion ————————————————————————————
#region ————————————- Parameters ————————————–
$ProgressPreference = 'SilentlyContinue'
#endregion ————————————————————————————
#region ————————————- Functions —————————————
Function script:Invoke-WebRequestPro {
Param ($URL,$Headers,$Method)
try
{
$WebRequest = Invoke-WebRequest Uri $URL Method $Method Headers $Headers UseBasicParsing
}
catch
{
$Response = $_
$WebRequest = [PSCustomObject]@{
Message = $response.Exception.Message
StatusCode = $response.Exception.Response.StatusCode
StatusDescription = $response.Exception.Response.StatusDescription
}
}
Return $WebRequest
}
# Function to get all entries in the catalog
Function Get-WUCatalogEntries {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[ValidateSet('Quality','Feature','Driver')]
[string]$filter
)
switch ($filter) {
Quality { $filterstring = 'microsoft.graph.windowsUpdates.qualityUpdateCatalogEntry' }
Feature { $filterstring = 'microsoft.graph.windowsUpdates.featureUpdateCatalogEntry' }
# Driver { $filterstring = 'microsoft.graph.windowsUpdates.driverUpdateCatalogEntry' } # doesn't work yet
}
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/catalog/entries?`$filter=isof('$filterstring')"
$headers = @{'Authorization'="Bearer " + $GraphToken}
$GraphRequest = Invoke-WebRequestPro URL $URL Headers $headers Method GET
return $GraphRequest
}
# Function to get a specific entry in the catalog
Function Get-WUCatalogEntry {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[string]$id
)
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/products/FindByCatalogId(catalogID='$id')?expand=revisions(`$expand=knowledgeBaseArticle),knownIssues(`$expand=originatingKnowledgeBaseArticle,resolvingKnowledgeBaseArticle)"
$headers = @{'Authorization'="Bearer " + $GraphToken}
$GraphRequest = Invoke-WebRequestPro URL $URL Headers $headers Method GET
return $GraphRequest
}
# Function to get Windows Product Editions
Function Get-WindowsProductEditions {
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/products?expand=editions"
$headers = @{'Authorization'="Bearer " + $GraphToken}
$GraphRequest = Invoke-WebRequestPro URL $URL Headers $headers Method GET
return $GraphRequest
}
#endregion ————————————————————————————
#region ————————————- Authentication ———————————-
# For testing
$script:GraphToken = Get-EntraAccessToken # https://gist.github.com/SMSAgentSoftware/e0737d683d4301767362c2a9587fd09e
# Managed identity authentication
#$null = Connect-AzAccount -Identity
#$script:GraphToken = (Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString -ErrorAction Stop).Token | ConvertFrom-SecureString -AsPlainText
#endregion ————————————————————————————
#region ————————————- Quality Updates ———————————
Write-Output "Retrieving Quality Update Catalog Entries…"
$CatalogEntries = Get-WUCatalogEntries filter Quality
if ($CatalogEntries.StatusCode -ne 200) {
Write-Error "Error retrieving catalog: $($CatalogEntries.StatusCode) $($CatalogEntries.StatusDescription)"
return
}
$Catalog = ($CatalogEntries.Content | ConvertFrom-Json).value
if ($Catalog.Count -eq 0) {
Write-Error "No catalog entries found."
return
}
# List containers
$CatalogList = [System.Collections.Generic.List[PSCustomObject]]::new()
$RevisionList = [System.Collections.Generic.List[PSCustomObject]]::new()
$KnownIssueList = [System.Collections.Generic.List[PSCustomObject]]::new()
# Process each catalog entry
Write-Output "Processing Quality Update Catalog Entries…"
foreach ($entry in $Catalog) {
# Add to the CatalogList
$CatalogList.Add($entry)
if ($entry.qualityUpdateCadence -ne "unknownFutureValue") # items with an 'unknownFutureValue' have no revisions
{
# Get the individual catalog entry
$UpdateResponse = Get-WUCatalogEntry id $entry.Id
if ($UpdateResponse.StatusCode -ne 200) {
Write-Error "Error retrieving catalog entry for '$($entry.displayName)': $($UpdateResponse.StatusCode) $($UpdateResponse.StatusDescription)"
continue
}
$Update = ($UpdateResponse.Content | ConvertFrom-Json).value
# Process each update
foreach ($item in $Update)
{
if ($item.name -notmatch "Server")
{
# Extract the revisions
[array]$revisions = $item.revisions
foreach ($revision in $revisions)
{
$RevisionObject = [PSCustomObject]@{
catalogUpdateId = $entry.id
revisionId = $item.id
revisionName = $item.name
revisionGroupName = $item.groupName
fullBuildNumber = $revision.id
displayName = $revision.displayName
releaseDateTime = $revision.releaseDateTime
isHotPatchUpdate = $revision.isHotPatchUpdate
version = $revision.version
product = $revision.product
buildNumber = $revision.osBuild.buildNumber
updateBuildRevision = $revision.osBuild.updateBuildRevision
knowledgeBaseArticleId = $revision.knowledgeBaseArticle.id
knowledgeBaseArticleUrl = $revision.knowledgeBaseArticle.Url
}
$RevisionList.Add($RevisionObject)
}
# Extract the known issues
[array]$knownIssues = $item.knownIssues
foreach ($knownIssue in $knownIssues)
{
$KnownIssueObject = [PSCustomObject]@{
revisionId = $item.id
revisionName = $item.name
revisionGroupName = $item.groupName
id = $knownIssue.id
status = $knownIssue.status
webViewUrl = $knownIssue.webViewUrl
description = $knownIssue.description
startDateTime = $knownIssue.startDateTime
title = $knownIssue.title
resolvedDateTime = $knownIssue.resolvedDateTime
lastUpdatedDateTime = $knownIssue.lastUpdatedDateTime
safeguardHoldIds = ($knownIssue.safeguardHoldIds -join ",")
latestDetail = ($knownIssue.knownIssueHistories | Sort createdDateTime Descending | Select first 1).body.content
originatingKnowledgeBaseArticleId = $knownIssue.originatingKnowledgeBaseArticle.id
resolvingKnowledgeBaseArticleId = $knownIssue.resolvingKnowledgeBaseArticle.id
originatingKnowledgeBaseArticleUrl = $knownIssue.originatingKnowledgeBaseArticle.url
resolvingKnowledgeBaseArticleUrl = $knownIssue.resolvingKnowledgeBaseArticle.url
}
$KnownIssueList.Add($KnownIssueObject)
}
}
}
}
}
#endregion ————————————————————————————
#region ————————————- Feature Updates ———————————
Write-Output "Retrieving Feature Update Catalog Entries…"
$Editions = Get-WindowsProductEditions
if ($Editions.StatusCode -ne 200) {
Write-Error "Error retrieving Windows product editions: $($Editions.StatusCode) $($Editions.StatusDescription)"
return
}
$FilteredEditions = ($Editions.Content | ConvertFrom-Json).value | where {$_.groupName -notmatch "Server" -and $_.groupName -ne "Previous versions"}
# List containers
$EditionsList = [System.Collections.Generic.List[PSCustomObject]]::new()
$ServicingPeriodsList = [System.Collections.Generic.List[PSCustomObject]]::new()
Write-Output "Processing Feature Update Catalog Entries…"
# Process each feature update
foreach ($item in $FilteredEditions)
{
# Extract the editions
[array]$editions = $item.editions
foreach ($edition in ($editions | where {$_.name -notmatch "Server"}))
{
$EditionObject = [PSCustomObject]@{
revisionId = $item.id
revisionName = $item.name
revisionGroupName = $item.groupName
id = $edition.id
name = $edition.name
releasedName = $edition.releasedName
deviceFamily = $edition.deviceFamily
isInService = $edition.isInService
generalAvailabilityDateTime = $edition.generalAvailabilityDateTime
endOfServiceDateTime = $edition.endOfServiceDateTime
}
$EditionsList.Add($EditionObject)
# Extract the servicing periods into a separate list
[array]$servicingPeriods = $edition.servicingPeriods
foreach ($servicingPeriod in $servicingPeriods)
{
$ServicingPeriodsObject = [PSCustomObject]@{
revisionId = $item.id
revisionName = $item.name
revisionGroupName = $item.groupName
id = $edition.id
name = $edition.name
releasedName = $edition.releasedName
deviceFamily = $edition.deviceFamily
isInService = $edition.isInService
generalAvailabilityDateTime = $edition.generalAvailabilityDateTime
endOfServiceDateTime = $edition.endOfServiceDateTime
servicingPeriodName = $servicingPeriod.name
servicingPeriodStartDateTime = $servicingPeriod.startDateTime
servicingPeriodEndDateTime = $servicingPeriod.endDateTime
}
$ServicingPeriodsList.Add($ServicingPeriodsObject)
}
}
}
#endregion ————————————————————————————
#region ————————————- Output Tables ———————————–
# Prepare datatables. This is optional and makes it easier to import into SQL server database
$CatalogTable = [System.Data.DataTable]::new()
$RevisionTable = [System.Data.DataTable]::new()
$KnownIssueTable = [System.Data.DataTable]::new()
$EditionsTable = [System.Data.DataTable]::new()
$ServicingPeriodsTable = [System.Data.DataTable]::new()
# Catalog list
$CatalogList = $CatalogList | Select id,displayName,releaseDateTime,isExpeditable,qualityUpdateClassification,shortName,qualityUpdateCadence
$CatalogList |
Get-Member MemberType NoteProperty |
ForEach-Object {
if ($_.Name -in ("releaseDateTime"))
{
[void]$CatalogTable.Columns.Add($_.Name,[DateTime])
}
else
{
[void]$CatalogTable.Columns.Add($_.Name,[System.String])
}
}
foreach ($item in $CatalogList) {
$row = $CatalogTable.NewRow()
foreach ($col in $CatalogTable.Columns)
{
$entry = $item.$($col.ColumnName)
if ($null -eq $entry)
{
$row[$col.ColumnName] = [System.DBNull]::Value
}
else
{
$row[$col.ColumnName] = $entry
}
}
[void]$CatalogTable.Rows.Add($row)
}
# Revision list
$RevisionList |
Get-Member MemberType NoteProperty |
ForEach-Object {
if ($_.Name -in ("releaseDateTime"))
{
[void]$RevisionTable.Columns.Add($_.Name,[DateTime])
}
else
{
[void]$RevisionTable.Columns.Add($_.Name,[System.String])
}
}
foreach ($item in $RevisionList) {
$row = $RevisionTable.NewRow()
foreach ($col in $RevisionTable.Columns)
{
$entry = $item.$($col.ColumnName)
if ($null -eq $entry)
{
$row[$col.ColumnName] = [System.DBNull]::Value
}
else
{
$row[$col.ColumnName] = $entry
}
}
[void]$RevisionTable.Rows.Add($row)
}
# Known issue list
$KnownIssueList |
Get-Member MemberType NoteProperty |
ForEach-Object {
if ($_.Name -in ("startDateTime","resolvedDateTime","lastUpdatedDateTime"))
{
[void]$KnownIssueTable.Columns.Add($_.Name,[DateTime])
}
else
{
[void]$KnownIssueTable.Columns.Add($_.Name,[System.String])
}
}
foreach ($item in $KnownIssueList) {
$row = $KnownIssueTable.NewRow()
foreach ($col in $KnownIssueTable.Columns)
{
$entry = $item.$($col.ColumnName)
if ($null -eq $entry)
{
$row[$col.ColumnName] = [System.DBNull]::Value
}
else
{
$row[$col.ColumnName] = $entry
}
}
[void]$KnownIssueTable.Rows.Add($row)
}
# Editions list
$EditionsList |
Get-Member MemberType NoteProperty |
ForEach-Object {
if ($_.Name -in ("generalAvailabilityDateTime","endOfServiceDateTime"))
{
[void]$EditionsTable.Columns.Add($_.Name,[DateTime])
}
else
{
[void]$EditionsTable.Columns.Add($_.Name,[System.String])
}
}
foreach ($item in $EditionsList) {
$row = $EditionsTable.NewRow()
foreach ($col in $EditionsTable.Columns)
{
$entry = $item.$($col.ColumnName)
if ($null -eq $entry)
{
$row[$col.ColumnName] = [System.DBNull]::Value
}
else
{
$row[$col.ColumnName] = $entry
}
}
[void]$EditionsTable.Rows.Add($row)
}
# Servicing periods list
$ServicingPeriodsList |
Get-Member MemberType NoteProperty |
ForEach-Object {
if ($_.Name -in ("generalAvailabilityDateTime","endOfServiceDateTime","servicingPeriodStartDateTime","servicingPeriodEndDateTime"))
{
[void]$ServicingPeriodsTable.Columns.Add($_.Name,[DateTime])
}
else
{
[void]$ServicingPeriodsTable.Columns.Add($_.Name,[System.String])
}
}
foreach ($item in $ServicingPeriodsList) {
$row = $ServicingPeriodsTable.NewRow()
foreach ($col in $ServicingPeriodsTable.Columns)
{
$entry = $item.$($col.ColumnName)
if ($null -eq $entry)
{
$row[$col.ColumnName] = [System.DBNull]::Value
}
else
{
$row[$col.ColumnName] = $entry
}
}
[void]$ServicingPeriodsTable.Rows.Add($row)
}
#endregion ————————————————————————————