Loosely Decoupled
GuideWindows ServerAutomation

List Patch Tuesday KBs and CVEs with PowerShell and the MSRC API

Automate fetching the month’s Patch Tuesday KBs and CVEs in plain PowerShell, as objects you can filter by product, severity or exploit status.

A sysadmin calmly pulls one cyan index card from a card-catalogue drawer while a tin office robot staggers past with a crate of tangled machinery she does not need.
On this page

TL;DR

Save the script from the next section as PatchTuesday.ps1, dot-source it, and run:

PowerShell
. ./PatchTuesday.ps1
$release = Get-PatchTuesdayRelease | Sort-Object Released -Descending | Select-Object -First 1
$release | Get-PatchTuesdayKb
$release | Get-PatchTuesdayCve -Product 'Windows Server 2025'

The $release line takes the newest release. The next line lists the KBs it shipped, and the last lists every CVE that affects products whose name contains Windows Server 2025, with severity, CVSS score, exploit status, and the KBs that fix it. The script ran unchanged on PowerShell 7 (macOS) and Windows PowerShell 5.1 (Windows Server 2025), installs no module, and sends no key or token. A Windows client might first need the execution policy change under Common failures.

If Get-PatchTuesdayRelease returns nothing, you passed -Days with a value shorter than the gap since the last Patch Tuesday. Leave the default.

I already had a Python tool that pulls this data from Microsoft’s Security Response Center (MSRC) API, ms-patch-tuesday-fetcher (opens in a new tab), and the Python version has its own walkthrough. This page is the same job for a Windows admin who has no Python and wants no module. The script was written on October 21, 2026 and lives on this page, not in that repository.

Tested with

PowerShell
7.6.7 on macOS 27.0.1
Windows PowerShell
5.1.26100.33648 on Windows Server 2025 Datacenter
Date
October 2026

Save the script and load it

Save this as PatchTuesday.ps1 in the folder you work from. It defines five functions and runs nothing by itself.

PatchTuesday.ps1
# Patch Tuesday KB articles and CVEs from the MSRC API, in PowerShell only.
# Same two endpoints as https://github.com/EmmanuelTsouris/ms-patch-tuesday-fetcher.

$ReleaseNoteUri = 'https://api.msrc.microsoft.com/sug/v2.0/en-US/releaseNote'
$CvrfUri        = 'https://api.msrc.microsoft.com/cvrf/v3.0/cvrf'

function Get-PatchTuesdayRelease {
    param([int]$Days = 35)

    $notes  = Invoke-RestMethod -Uri $ReleaseNoteUri -Headers @{ Accept = 'application/json' } -TimeoutSec 30
    $cutoff = [datetimeoffset]::UtcNow.AddDays(-$Days)

    foreach ($note in $notes.value) {
        # PowerShell 7 may already have turned the string into a DateTime.
        $released = if ($note.releaseDate -is [datetime]) {
            [datetimeoffset]$note.releaseDate
        } else {
            [datetimeoffset]::Parse($note.releaseDate, [cultureinfo]::InvariantCulture)
        }
        if ($released -ge $cutoff) {
            [pscustomobject]@{
                Title       = $note.title
                Released    = $released
                CvrfId      = $note.releaseNumber
                Description = $note.description
            }
        }
    }
}

# The description is HTML. Return each table as its header cells and its rows.
function Get-HtmlTable {
    param([string]$Html)

    foreach ($table in [regex]::Matches($Html, '(?s)<table>.*?</table>')) {
        $rows = foreach ($tr in [regex]::Matches($table.Value, '(?s)<tr>(.*?)</tr>')) {
            , @([regex]::Matches($tr.Groups[1].Value, '(?s)<t[hd][^>]*>(.*?)</t[hd]>') |
                ForEach-Object { [System.Net.WebUtility]::HtmlDecode(($_.Groups[1].Value -replace '<[^>]+>', '').Trim()) })
        }
        [pscustomobject]@{ Header = $rows[0]; Rows = @($rows | Select-Object -Skip 1) }
    }
}

function Get-PatchTuesdayKb {
    param([Parameter(ValueFromPipeline)]$Release)
    process {
        foreach ($table in Get-HtmlTable $Release.Description) {
            foreach ($row in $table.Rows) {
                # A KB row's first cell is a bare number; the notable CVE table also has links.
                if ($row.Count -ge 2 -and $row[0] -match '^\d+$') {
                    [pscustomobject]@{ KB = "KB$($row[0])"; Product = $row[1] }
                }
            }
        }
    }
}

function Get-PatchTuesdayNotableCve {
    param([Parameter(ValueFromPipeline)]$Release)
    process {
        foreach ($table in Get-HtmlTable $Release.Description) {
            if ($table.Header[0] -ne 'CVE ID') { continue }
            foreach ($row in $table.Rows) {
                [pscustomobject]@{ CVE = $row[0]; Title = $row[1]; Notable = $row[2] }
            }
        }
    }
}

function Get-PatchTuesdayCve {
    param([Parameter(ValueFromPipeline)]$Release, [string]$Product)
    process {
        $doc   = Invoke-RestMethod -Uri "$CvrfUri/$($Release.CvrfId)" -Headers @{ Accept = 'application/json' } -TimeoutSec 120
        $names = @{}
        foreach ($p in $doc.ProductTree.FullProductName) { $names[$p.ProductID] = $p.Value }
        $rank  = @{ Critical = 4; Important = 3; Moderate = 2; Low = 1 }

        foreach ($vuln in $doc.Vulnerability) {
            $ids = @($vuln.ProductStatuses.ProductID | Select-Object -Unique)
            if ($Product) { $ids = @($ids | Where-Object { $names[$_] -like "*$Product*" }) }
            if ($Product -and -not $ids) { continue }
            $affected = @($ids | ForEach-Object { $names[$_] })

            # Threat Type 3 is severity and Type 1 the exploit status, as the Python tool reads them.
            # Severity and CVSS are given per product, so with -Product only the kept
            # products count; the exploit status names no product and covers them all.
            $ratings = @($vuln.Threats | Where-Object Type -eq 3)
            $scores  = @($vuln.CVSSScoreSets)
            if ($Product) {
                $ratings = @($ratings | Where-Object { $_.ProductID | Where-Object { $_ -in $ids } })
                $scores  = @($scores | Where-Object { $_.ProductID | Where-Object { $_ -in $ids } })
            }
            $severity = $ratings | ForEach-Object { $_.Description.Value } |
                Sort-Object { $rank[$_] } -Descending | Select-Object -First 1
            $status   = ($vuln.Threats | Where-Object Type -eq 1 | ForEach-Object { $_.Description.Value }) -join ';'
            # Keep only the KBs that fix one of the products still in the list.
            $kbs = @($vuln.Remediations |
                Where-Object { $_.Description.Value -match '^\d+$' -and ($_.ProductID | Where-Object { $_ -in $ids }) } |
                ForEach-Object { "KB$($_.Description.Value)" } | Sort-Object -Unique)

            [pscustomobject]@{
                CVE       = $vuln.CVE
                Title     = $vuln.Title.Value
                Severity  = $severity
                CVSS      = ($scores.BaseScore | Measure-Object -Maximum).Maximum
                Exploited = $status -match 'Exploited:Yes'
                KBs       = $kbs
                Products  = $affected
            }
        }
    }
}

Dot-source the file so the functions exist in your current session:

PowerShell
. ./PatchTuesday.ps1

The functions only read from api.msrc.microsoft.com and change nothing on your system.

Fetch the release

Get-PatchTuesdayRelease is the entry point; every other function takes its output by pipeline. It makes one request for the release notes and keeps the ones released within -Days days of now, 35 by default.

Command
PS> $release = Get-PatchTuesdayRelease | Sort-Object Released -Descending | Select-Object -First 1
PS> $release | Format-List Title, Released, CvrfId

The call returned one release: Title October 2026 Security Updates, Released 10/13/2026 7:00:00 AM -07:00, CvrfId 2026-Oct. PowerShell 7 shows the time in your machine’s time zone and date format, so the time and offset you see can differ; it is the same moment. The CvrfId is what Get-PatchTuesdayCve appends to the document URL later.

When two Patch Tuesdays are 28 days apart, the 35-day window also catches the previous month for a week. The function returns releases in the order the API sends them, so sort by Released and keep the first to get the newest. The later sections use $release for that reason.

PowerShell 7 can convert the release date to a [datetime] while it parses the JSON, and Windows PowerShell 5.1 leaves it a string. The function handles both, so the same code runs on each.

List the KB articles

The release note’s description is HTML. Get-HtmlTable, a helper the other functions call, reads its tables with regular expressions, strips the tags, and decodes the entities, so no HTML parser module is involved. Get-PatchTuesdayKb keeps the rows whose first cell is a bare number.

PowerShell
$release | Get-PatchTuesdayKb

Each result has a KB value such as KB5126646 and the Product it applies to.

List the notable CVE records

The release note also carries a table headed CVE ID that names the CVEs Microsoft flags as exploited or publicly disclosed. Get-PatchTuesdayNotableCve reads that table.

PowerShell
$release | Get-PatchTuesdayNotableCve

The October 2026 run returned two: CVE-2026-96951, noted as Exploitation Detected, and CVE-2026-108398, noted as Publicly disclosed.

List every CVE, optionally for one product

Get-PatchTuesdayCve downloads the full CVRF document for the release, which is the heavy call. The script allows it 120 seconds. Run it once and keep the result if you need several filters:

PowerShell
$all = $release | Get-PatchTuesdayCve
$all.Count

Each CVE comes back as an object with CVE, Title, Severity, CVSS, Exploited, KBs, and Products. The script derives three of them from the document:

  • Severity is the most severe value among the CVE’s severity threats.
  • Exploited is true when the exploit-status threat contains Exploited:Yes.
  • CVSS is the highest base score in the CVE’s score sets.

Severity and score sets are listed per product, so with -Product the script counts only the entries for the products that matched. A CVE rated Critical on one product and Important on yours comes back as Important. In October’s data, CVE-2026-102554 is Moderate for httpd and Important for another Azure Linux package, and -Product 'httpd' returns Moderate. The exploit-status threat names no product, so it applies to every product.

The threat-type numbers behind the first two follow the Python tool, and I didn’t check them against Microsoft’s CVRF documentation.

-Product is a case-insensitive wildcard match on the product name. With it, the Products list holds only the matching products, the KBs list holds only the KBs that fix one of them, and CVEs that affect none of them are dropped. If you adapt the script, skip a CVE with no affected products only when -Product is set.

PowerShell
$release | Get-PatchTuesdayCve -Product 'Windows Server 2025'

The counts for October 2026 are under Confirm it worked.

Filter and export the objects

The output is objects, so the filters are ordinary Where-Object and Sort-Object calls. To list the exploited or Critical CVEs for one product, highest score first:

PowerShell
$release | Get-PatchTuesdayCve -Product 'Windows Server 2025' |
    Where-Object { $_.Severity -eq 'Critical' -or $_.Exploited } |
    Sort-Object CVSS -Descending |
    Select-Object -First 5 CVE, Severity, CVSS, Exploited, KBs |
    Format-Table

Expected output

CVE             Severity  CVSS Exploited KBs
---             --------  ---- --------- ---
CVE-2026-100202 Critical 9.800     False KB5126646
CVE-2026-96934  Critical 9.800     False KB5126646
CVE-2026-70153  Critical 9.800     False KB5126646
CVE-2026-105016 Critical 9.800     False KB5126646
CVE-2026-70147  Critical 9.800     False KB5126646

CVSS prints as 9.800 because Measure-Object -Maximum returns a double. The exploited CVE doesn’t appear in this list because its score is lower than five Critical ones. To find it, sort on Exploited or filter on it alone.

The exploited CVE as ConvertTo-Json shows what one object holds:

JSON
{
  "CVE": "CVE-2026-96951",
  "Title": "Windows Shell Security Feature Bypass Vulnerability",
  "Severity": "Important",
  "CVSS": 8.8,
  "Exploited": true,
  "KBs": [
    "KB5126646"
  ],
  "Products": [
    "Windows Server 2025",
    "Windows Server 2025 (Server Core installation)"
  ]
}

Export-Csv works on the same objects. KBs and Products are arrays, and Export-Csv writes an array as the type name System.Object[]. Join them first. This writes patch-tuesday.csv in the current folder:

PowerShell
$all |
    Select-Object CVE, Title, Severity, CVSS, Exploited,
        @{ Name = 'KBs'; Expression = { $_.KBs -join ';' } },
        @{ Name = 'Products'; Expression = { $_.Products -join ';' } } |
    Export-Csv -Path patch-tuesday.csv -NoTypeInformation

Confirm it worked

Run the functions against the same release and compare. The numbers below are from the October 2026 release and change every month:

PowerShell
($release | Get-PatchTuesdayKb | Measure-Object).Count
($release | Get-PatchTuesdayNotableCve | Measure-Object).Count

For October 2026 these were 19 and 2. The full list was 2,575 CVEs, and -Product 'Windows Server 2025' gave 1,068, of which 37 were Critical and one was exploited. A different month gives different numbers.

Where this was tested

No TLS setting was needed on the Windows machine, where [Net.ServicePointManager]::SecurityProtocol read SystemDefault.

StepPowerShell 7.6.7, macOS 27.0.1Windows PowerShell 5.1.26100.33648, Windows Server 2025 Datacenter
Release fetch1.6 s5.9 s
Full CVE list for Windows Server 20257.5 s54.8 s

The Windows machine was a small Azure VM and the other was an Apple silicon Mac, so the timings are not a clean comparison of the two PowerShell versions. Windows PowerShell 5.1 parsed the 2.3 MB release-note JSON and the 27 MB CVRF document with no error.

Windows PowerShell 5.1 returned the same counts as PowerShell 7.

Do this with a coding agent

Paste this prompt into your own local coding agent, with the script from this page where the placeholder is. It writes one file and reads from one API. Replace the product placeholders first.

Text
Get this month's Patch Tuesday information for me in PowerShell. Change
nothing on this system except creating one file, PatchTuesday.ps1, in the
current folder. Install no module and no other software.

1. Save the script below to PatchTuesday.ps1 exactly as given.
   Run steps 2 to 4 in one PowerShell process, for example as one
   pwsh -NoProfile -Command (or powershell -NoProfile -Command)
   invocation or one script, because the functions and $release exist only
   in the session that dot-sourced the file.
2. In PowerShell, dot-source it: . ./PatchTuesday.ps1
   If dot-sourcing fails because running scripts is disabled, run
   Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned in the
   same session and dot-source again. Never change the policy at any other scope.
3. Run $release = Get-PatchTuesdayRelease -Days 35 | Sort-Object Released -Descending | Select-Object -First 1
   If it returns nothing, say so and stop. If the window held more than one
   release, this keeps only the newest.
4. For each of these products, run once (one CVRF download per product):
   $release | Get-PatchTuesdayCve -Product '<product-name>'
   Products: <product-name-1>, <product-name-2>
5. Report, for each product, the CVEs that are exploited or Critical, with
   their severity, CVSS score, and the KBs that fix them.

The API can answer 429 Too Many Requests. Make at most one API call for the release plus one per
product. If a call fails with 429, stop, report it, and do not retry.

<paste the contents of PatchTuesday.ps1 here>

Common failures

Running scripts is disabled on this system

Windows PowerShell 5.1 on Windows client editions defaults to the Restricted execution policy, which blocks dot-sourcing (about_Execution_Policies (opens in a new tab)). Allow scripts for this session only, then dot-source again:

PowerShell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
. ./PatchTuesday.ps1

If you downloaded the file rather than pasting it, run Unblock-File ./PatchTuesday.ps1 first.

Call returns HTTP 429

HTTP 429 means the client sent too many requests in a given amount of time, and the server might include a Retry-After header that says how long to wait (MDN on 429 (opens in a new tab)). I didn’t see a 429 from the MSRC API in these runs, so I can’t tell you what triggers it there. Wait, then run the call again. Store the result of Get-PatchTuesdayCve in a variable and filter that, instead of calling it again for each filter.

Get-PatchTuesdayRelease finds nothing

The function keeps only releases within -Days days of now. Patch Tuesdays can fall up to 35 days apart, and a smaller window returns nothing when the last one is older than the window. Run it without -Days, which uses 35.

PowerShell
Get-PatchTuesdayRelease

CVE call is slow on Windows PowerShell 5.1

The CVRF document is about 27 MB, and the step took 54.8 s on Windows PowerShell 5.1 in the run above, against 7.5 s on PowerShell 7 on a different machine. It parsed with no error, so wait for it. The script already allows 120 seconds.

New articles, by email.

A short note when something new is published.