PowerShell: WSUS Audit-Compliance Report and Automatic Cleanup


Its been long since I wrote anything on WSUS. As Windows world evolved, many of the troubleshooting steps might be bit out-dated now but WSUS functioning and basics still remain more or less same.

Today I wish to take another aspect of WSUS and talk about WSUS Reporting part and regular clean-up.

WSUS Reporting:

WSUS has a comprehensive reporting system built in and if Report Viewer installed over the server, one can variety of reports, but the same remains limited to a pre-set and also one server only. Let’s think of a scenario, where one might have more than dozens of WSUS servers catering thousands of machines and rather logging over each one or attaching them into one console, we want to have a consolidated report to see if patches getting installed well and also whether team is meeting compliance by install each single patch of (M-1) month.

Now what my PowerShell code does? It bring a report like below in csv format:

Sample

This would include how many patches are needed on which machines, when those machines last contacted their respective WSUS servers, what IP and OS do they have and which particular updates are pending on them and those pending updates were released when. Also it tells count of those updates which have been downloaded or pending download or pending reboot of machines to complete the installation.

I believe this pretty much covers what a system admin might need to get an overview of issues and to address them. Apart from this, what the script does is, it creates some more CSV files, one against each WSUS server mentioning that which particular updates were released between the period of 1st day of (M-2) month (if today is March, then we talking about January 1st) and Second Monday of (M-1) month means just one day before Patch Tuesday. Based on this data, one might check if any of the last month patches are missing on any of the servers. I have written a Excel Macro code around that as well but that still needs updating and make compatible for general usage so would post that at some later time.

Also notice, in code, there are two arrays of WSUS servers. Essential difference between the two are the connection lines. One connects over 8530 port while other connects on port 80, there might be SSL and non-SSL options as well, which can get handled by the second attribute passed depending on your scenario.


# Author : Nitish Kumar
# Performs an audit of WSUS
# Outputs the results to a text file.
# version 1.2
# 21 May 2017

[void][reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration")

$firstdayofmonth = [datetime] ([string](get-date).AddMonths(-1).month + "/1/" + [string](get-date).year)
$DomainName = "."+ $env:USERDNSDOMAIN

# Create empty arrays to contain collected data.
$UpdateStatus = @()
$SummaryStatus = @()

# For WSUS servers catering servers
$WSUSServers = ("XYZ","ABC")

$a0 = ($WSUSServers | measure).count
$b0 = 0

$thisDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$logFile = "WSUSAuditReports_$((Get-Date).ToString('MM-dd-yyyy_hh-mm-ss')).txt"
Start-Transcript -Path $thisDir\$logFile

ForEach ($WS1 in $WSUSServers) {
write-host "Working on $WS1 ..."	-foregroundcolor Green
$b0 = $b0+1

try {

Try {
$WSUS = [Microsoft.UpdateServices.Administration.AdminProxy]::getUpdateServer($WS1,$false,8530)
}
Catch {
$WSUS = [Microsoft.UpdateServices.Administration.AdminProxy]::getUpdateServer($WS1,$false,80)
}

$ComputerScope = New-Object Microsoft.UpdateServices.Administration.ComputerTargetScope
$UpdateScope = New-Object Microsoft.UpdateServices.Administration.UpdateScope
$UpdateScope.ApprovedStates = [Microsoft.UpdateServices.Administration.ApprovedStates]::LatestRevisionApproved
$updatescope.IncludedInstallationStates = [Microsoft.UpdateServices.Administration.UpdateInstallationStates]::All

$ComputerTargetGroups = $WSUS.GetComputerTargetGroups() | Where {$_.Name -eq 'All Computers'}
$MemberOfGroup = $WSUS.getComputerTargetGroup($ComputerTargetGroups.Id).GetComputerTargets()

write-host "Connected and Fetching the data from $WS1 for all computers connecting to it..."
$Alldata = $WSUS.GetSummariesPerComputerTarget($updatescope, $computerscope)
$a = ($Alldata | measure).count
$b = 0

write-host "Data recieved from $WS1 for all computers connecting to it..."
Foreach ($Object in $Alldata) {
$b = $b+1
write-host "Getting data from number $b of all $a computers connecting to $WS1 ($b0 of $a0)..."	-foregroundcolor Yellow
Foreach ($object1 in $MemberOfGroup) {
If ($object.computertargetid -match $object1.id) {

$ComputerTargetToUpdate = $WSUS.GetComputerTargetByName($object1.FullDomainName)
$NeededUpdate = $ComputerTargetToUpdate.GetUpdateInstallationInfoPerUpdate() | where {($_.UpdateApprovalAction -eq "install") -and (($_.UpdateInstallationState -eq "Downloaded") -or ($_.UpdateInstallationState -eq "Notinstalled") -or ($_.UpdateInstallationState -eq "Failed"))	}

$FailedUpdateReport = @()
$NeededUpdateReport = @()
$NeededUpdateDateReport = @()

if ($NeededUpdate -ne $null) {
foreach ($Update in $NeededUpdate) {
$NeededUpdateReport += ($WSUS.GetUpdate([Guid]$Update.updateid)).KnowledgebaseArticles
$NeededUpdateDateReport += ($WSUS.GetUpdate([Guid]$Update.updateid)).ArrivalDate.ToString("dd/MM/yyyy ")
}
}

$object1 | select -ExpandProperty FullDomainName
$myObject1 = New-Object -TypeName PSObject
$myObject1 | add-member -type Noteproperty -Name Server -Value (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, "")
$myObject1 | add-member -type Noteproperty -Name NotInstalledCount -Value $object.NotInstalledCount
$myObject1 | add-member -type Noteproperty -Name NotApplicable -Value $object.NotApplicableCount
$myObject1 | add-member -type Noteproperty -Name DownloadedCount -Value $object.DownloadedCount
$myObject1 | add-member -type Noteproperty -Name InstalledCount -Value $object.InstalledCount
$myObject1 | add-member -type Noteproperty -Name InstalledPendingRebootCount -Value $object.InstalledPendingRebootCount
$myObject1 | add-member -type Noteproperty -Name FailedCount -Value $object.FailedCount
$myObject1 | add-member -type Noteproperty -Name NeededCount -Value ($NeededUpdate | measure).count
$myObject1 | add-member -type Noteproperty -Name Needed -Value $NeededUpdateReport
$myObject1 | add-member -type Noteproperty -Name LastSyncTime -Value $object1.LastSyncTime
$myObject1 | add-member -type Noteproperty -Name IPAddress -Value $object1.IPAddress
$myObject1 | add-member -type Noteproperty -Name OS -Value $object1.OSDescription
$myObject1 | add-member -type Noteproperty -Name NeededDate -Value $NeededUpdateDateReport
$SummaryStatus += $myObject1
}
}
}

$SummaryStatus | select-object server,NeededCount,LastSyncTime,InstalledPendingRebootCount,NotInstalledCount,DownloadedCount,InstalledCount,FailedCount,@{Name="KB Numbers"; Expression = {$_.Needed}},@{Name="Arrival Date"; Expression = {$_.NeededDate}},NotApplicable,IPAddress,OS|export-csv -notype $Env:Userprofile\desktop\AllServersStatus.csv

write-host "Connected with $WS1 and finding patches for last month schedule .."
# Find patches from 1st day of (M-2) month to 2nd Monday of (M-1) month
$updatescope.FromArrivalDate = [datetime](get-date).Addmonths(-2).AddDays(-((Get-date).day-1))

$updatescope.ToArrivalDate = [datetime](0..31 | % {$firstdayofmonth.adddays($_) } | ? {$_.dayofweek -like "Mon*"})[1]
#[datetime](0..31 | % {$firstdayofmonth.adddays($_) } | ? {$_.dayofweek -like "Mon*"})[1]

$file1 = "$env:userprofile\desktop\Currentmonthupdates_"+$WS1+".csv"
$WSUS.GetSummariesPerUpdate($updatescope,$computerscope) |select-object @{L='UpdateTitle';E={($WSUS.GetUpdate([guid]$_.UpdateId)).Title}},@{L='Arrival Date';E={($WSUS.GetUpdate([guid]$_.UpdateId)).ArrivalDate}},@{L='KB Article';E={($WSUS.GetUpdate([guid]$_.UpdateId)).KnowledgebaseArticles}},@{L='NeededCount';E={($_.DownloadedCount+$_.NotInstalledCount)}},DownloadedCount,NotApplicableCount,NotInstalledCount,InstalledCount,FailedCount | Export-csv -Notype $file1

}
catch [Exception] {
write-host $_.Exception.GetType().FullName -foregroundcolor Red
write-host $_.Exception.Message -foregroundcolor Red
continue;
}
}

Stop-Transcript
notepad $logFile

Once the report task is done, let’s talk about one smaller one. Cleaning of WSUS not relevant files and Computers. WSUS itself provides a one click option for this but again, it doesn’t cater if you want to handle multiple servers at once nor gives you a text report of what happened in one pass. So, here goes the another script to solve that part.

Now this script considers that we are in a SCOM monitored (or may be some other tool) environment means we would get a few tickets when we run the clean-up as clean-up restarts the update service on each WSUS twice and also may trigger high CPU etc. The script stops before each WSUS server and takes a manual input from you while informing you that its going to work on which server and that server should be put into maintenance mode in SCOM before proceeding further. It logs everything into the same directory where the script is. The path can be changed in case needed.

Also if one wants to make it completely automated then read-host lines can be removed and also at the end of code, we can add some lines to ensure that the status of entire activity gets passed over email to relevant personnel.

# Author : Nitish Kumar
# Performs a cleanup of WSUS.
# Outputs the results to a text file.
# version 1.0
# 07 March 2017

[void][reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration")

# For all WSUS servers in Environment
$WSUSNon2k3 = ("abc001","xyz001")
$WSUS2k3 = ("cba001","zyx001")

# In case need to check for some individual server or few particular servers
#$WSUSNon2k3 = ("abc001")
#$WSUS2k3 = ("")

$AllWSUSCount = ($WSUSNon2k3 | measure).count + ($WSUS2k3 | measure).count
$WorkingonWSUS = 0

$thisDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$logFile = "WSUSCleanupResults_$((Get-Date).ToString('MM-dd-yyyy_hh-mm-ss')).txt"
Start-Transcript -Path $thisDir\$logFile

$Reply = "@"

ForEach ($WS1 in $WSUSNon2k3) {
$WorkingonWSUS = $WorkingonWSUS + 1
$WS1

Read-Host "Put $WS1 into maintenance mode. Once done,press Enter to continue ..." | Out-Null

write-host "Working on $WS1 ($WorkingonWSUS of $AllWSUSCount) ..."	-foregroundcolor Green
try {

$WSUS = [Microsoft.UpdateServices.Administration.AdminProxy]::getUpdateServer($WS1,$false,8530)

write-host "Connected with $WS1 ($WorkingonWSUS of $AllWSUSCount)and proceeding for cleanup ..."	-foregroundcolor Yellow
write-host "Connected with $WS1 ($WorkingonWSUS of $AllWSUSCount)and proceeding for cleanup ..."

$cleanupScope = new-object Microsoft.UpdateServices.Administration.CleanupScope;

$cleanupScope.CleanupObsoleteComputers = $true
$cleanupScope.DeclineSupersededUpdates = $true
$cleanupScope.DeclineExpiredUpdates         = $true
$cleanupScope.CleanupObsoleteUpdates     = $true
$cleanupScope.CleanupUnneededContentFiles = $true
$cleanupScope.CompressUpdates                  = $true

$cleanupManager = $WSUS.GetCleanupManager();
$cleanupManager.PerformCleanup($cleanupScope);

write-host "Cleaning done for $WS1 ($WorkingonWSUS of $AllWSUSCount) ..."	-foregroundcolor Yellow
}
catch [Exception] {

write-host $_.Exception.GetType().FullName -foregroundcolor Red
write-host $_.Exception.Message -foregroundcolor Red

continue;
}
}

ForEach ($WS1 in $WSUS2k3) {
$WorkingonWSUS = $WorkingonWSUS + 1
$WS1

Read-Host "Put $WS1 into maintenance mode. Once done,press Enter to continue ..." | Out-Null

write-host "Working on $WS1 ($WorkingonWSUS of $AllWSUSCount) ..."	-foregroundcolor Green

try {

$WSUS = [Microsoft.UpdateServices.Administration.AdminProxy]::getUpdateServer($WS1,$false,80)

write-host "Connected with $WS1 ($WorkingonWSUS of $AllWSUSCount)and proceeding for cleanup ..."	-foregroundcolor Yellow

$cleanupScope = new-object Microsoft.UpdateServices.Administration.CleanupScope;

$cleanupScope.CleanupObsoleteComputers = $true
$cleanupScope.DeclineSupersededUpdates = $true
$cleanupScope.DeclineExpiredUpdates         = $true
$cleanupScope.CleanupObsoleteUpdates     = $true
$cleanupScope.CleanupUnneededContentFiles = $true
$cleanupScope.CompressUpdates                  = $true

$cleanupManager = $WSUS.GetCleanupManager();
$cleanupManager.PerformCleanup($cleanupScope);
write-host "Cleaning done for $WS1 ($WorkingonWSUS of $AllWSUSCount) ..."	-foregroundcolor Yellow

}
catch [Exception] {

write-host $_.Exception.GetType().FullName -foregroundcolor Red
write-host $_.Exception.Message -foregroundcolor Red

continue;
}
}
Stop-Transcript
notepad $logFile

So these were my two cents. These scripts are still a work-in-progress and would improve based on feedbacks.

31 thoughts on “PowerShell: WSUS Audit-Compliance Report and Automatic Cleanup

  1. Excellent script. I have tried in my environment which runs windows 2008 R2 and windows 2012 R2. The script seems to work for some servers but some are failing. Below is the error message.
    ———–
    Microsoft.UpdateServices.Administration.WsusobjectNotfoundException
    The Specified item could not be found in the database
    Transcript stopped. output file is c:\xxx.csv
    ——
    Can you help to solve it?

    \bk

    1. Likely one of the array being passed is empty. Most likely the one with windows 2003.

      You can check which wsus are talking over which ports and accordingly can put them in relevant array.

      On Sun 18 Mar, 2018, 5:08 PM Nitish Kumar's Blog, wrote:

      >

  2. Hi Nitish,
    Thanks for the wonderful script. I needed some help to modify the script as per my requirement.
    We have 13 WSUS servers in our environment.
    One of them is the upstream server and remaining are downstream servers.
    I wanted to modify the script to get the computer status details of all the servers in one CSV file.
    When I specify all the WSUS servers in $WSUSServers = (“wsus1″, :wsus2”) like this, I am getting the computer details in one sheet.
    But is it possible to modify the script so that the computer details for each wsus server can be separated in the same file in different sheets per wsus server? If so could you please provide what code I need to add/modify?
    Also I wanted to add code to send the final csv file in mail.

    Let me know if you need any other details or you are not clear about my requirement.

    1. Also is it possible to add some code in the script which lists the latest installed patches for each computer as it is currently listing the not installed updates?

      1. As we are fetching the reports from WSUS and not from computers, so all it can say if an update is there or not or pending or download means whatever WSUS has. No install dates etc on individual computers. For that we need to probe the individual computers which wouldn’t be feasible and too much slow
        It’s possible to list installed patches but from WSUS side it would be large list which wouldn’t be suitable to CSV. Currently it’s listing “needed patches”

    2. Csv files are comma separated TXT files so it wouldn’t have sheets like Excel. What we can do is to produce different files for different WSUS servers with names like ServerStatus_wsus1.csv, ServerStatus_wsus2.csv

      After ForEach ($WS1 in $WSUSServers) { put $TempSummaryStatus = @() at line 28.

      Replace $SummaryStatus += $myObject1 with $TempSummaryStatus += $myObject1 at line 90

      Replace below line at line 95

      $SummaryStatus | select-object server,NeededCount,LastSyncTime,InstalledPendingRebootCount,NotInstalledCount,DownloadedCount,InstalledCount,FailedCount,@{Name=”KB Numbers”; Expression = {$_.Needed}},@{Name=”Arrival Date”; Expression = {$_.NeededDate}},NotApplicable,IPAddress,OS|export-csv -notype $Env:Userprofile\desktop\AllServersStatus.csv

      with

      $TempSummaryStatus | select-object server,NeededCount,LastSyncTime,InstalledPendingRebootCount,NotInstalledCount,DownloadedCount,InstalledCount,FailedCount,@{Name=”KB Numbers”; Expression = {$_.Needed}},@{Name=”Arrival Date”; Expression = {$_.NeededDate}},NotApplicable,IPAddress,OS|export-csv -notype C:\UpdateDetails\ServersStatus_$($WS1).csv
      $SummaryStatus += $TempSummaryStatus

  3. Hello Nitish ,

    Nice script its working for me but is it possible to you send report on e-mail id ??
    can you please help me

    Thanks,
    Madhukar

    1. Definitely.

      CSV Report can be sent over email which would include related details like patch KB, date, pending count, downloaded count etc.

      On Tue 5 Mar, 2019, 3:03 PM Nitish Kumar's Blog, wrote:

      >

  4. Hi, this is exactly what I am looking for. However when I run the script I am getting this error:

    Split-Path : Cannot bind argument to parameter ‘Path’ because it is null.
    At line:21 char:31
    + $thisDir = Split-Path -Parent $MyInvocation.MyCommand.Path
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Split-Path], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationErrorNullNotAllowed,Microsoft.PowerShell.Commands.SplitPathCommand

    Can you please help?

  5. Hi Nitesh, this has been a lifesaver, allows us to get all the detail from WSUS in an easy to use format.

    just looking at the scripts again before i posted an update on gh, line 24 of the cleanup script what’s the function of the $reply=”@” or is it a relic of the e-mail additions?

    I did some optomisations, added some error handling, and tweaked some of the variable names, but the scripts are here if you want to take a look:
    https://github.com/EnviableOne/WSUS-Maintainance

    1. getting below error

      System.Net.WebException
      The underlying connection was closed: An unexpected error occurred on a receive.
      Total Servers checked : 1
      Different updates needed : 0
      Time elapsed : 0:00:02:02.6109279

        1. Hi Ravi, I was tinkering with something when I uploaded the update. The current version should have a few improvements, and should work fine. I have 2008R2, 2012 and 2016 servers and they all respond as expected.

          1. Been little while since I checked my blog. Sorry for the late reply

            $Reply variable since redundant as I placed a stripped version of code. Would take a look over yours as well

            On Mon, 4 Jan, 2021, 9:22 pm Nitish Kumar's Blog, wrote:

            >

  6. PS>CommandInvocation(Out-Null): “Out-Null” getting following error as ouput, could you please check why is this getting.

  7. Hi Nitish Kumar,

    ###################Audit-Compliance Report###################
    Thanks a lot for the script, you Really saved my time. Is it possible to have a computer group?
    I have added the computer group in the script with 2 option. But the out put is empty.
    Option 1: #$myObject1 | add-member -type Noteproperty -Name TargetGroups -Value “All Computers”
    Option2 #$myObject1 | add-member -type Noteproperty -Name TargetGroups -Value $object1.TargetGroup

    Would you help me on this?

    Regards
    Anandkumar R

    1. Which line you trying to modify??

      If you looking to target any other computer group than all computers then likely you would need to make changes in line #44-45

      $ComputerTargetGroups = $WSUS.GetComputerTargetGroups() | Where {$_.Name -eq ‘All Computers’} $MemberOfGroup = $WSUS.getComputerTargetGroup($ComputerTargetGroups.Id).GetComputerTargets()

      Regards

      Nitish Kumar

      ________________________________

  8. Hi Nitish, appreciate the script. I have been trying to find a way to get the uptime/last reboot time of the server added to the report, any ideas?

        1. Look for the line
          $myObject1 | add-member -type Noteproperty -Name NeededDate -Value $NeededUpdateDateReport

          And add the below two lines after the same

          $myObject1 | add-member -type Noteproperty -Name LastBootTime -Value (Get-CIMInstance -Computername (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, “”) Win32_OperatingSystem).LastBootUptime
          $myObject1 | add-member -type Noteproperty -Name UptimeinHours -Value ((get-date) – (Get-CIMInstance -Computername (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, “”) Win32_OperatingSystem).LastBootUpTime).Totalhours

          Then search for line and add the two properties
          $SummaryStatus | select-object server,NeededCount,LastSyncTime,InstalledPendingRebootCount,NotInstalledCount,DownloadedCount,InstalledCount,FailedCount,@{Name=”KB Numbers”; Expression = {$_.Needed}},@{Name=”Arrival Date”; Expression = {$_.NeededDate}},NotApplicable,IPAddress,OS|export-csv -notype $Env:Userprofile\desktop\AllServersStatus.csv

          Replaced by

          $SummaryStatus | select-object server,NeededCount,LastSyncTime,InstalledPendingRebootCount,NotInstalledCount,DownloadedCount,InstalledCount,FailedCount,@{Name=”KB Numbers”; Expression = {$_.Needed}},@{Name=”Arrival Date”; Expression = {$_.NeededDate}},NotApplicable,IPAddress,OS, LastBootTime , Uptimeinhours |export-csv -notype $Env:Userprofile\desktop\AllServersStatus.csv

          1. Thank you Nitish! Now I am running into an issue if the computer/server is offline, then the script stops at that computer:

            + CategoryInfo : ConnectionError: (root\cimv2:Win32_OperatingSystem:String) [Get-CimInstance], CimException
            + FullyQualifiedErrorId : HRESULT 0x803381b9,Microsoft.Management.Infrastructure.CimCmdlets.GetCimInstanceCommand
            + PSComputerName :XXX

            I tired adding -ErrorAction SilentlyContinue but it is still stopping the script when one of these computers is queried.

            1. You can put in the for loop so that each computer get queried only if ping working

              If (Test-Connection $ws1 -count 1 -quiet) {
              ………………………….
              }

              Regards

              Nitish Kumar

              1. That is what I was trying and I added this at Line 71:
                }
                if (Test-Connection $myObject1 -Quiet -Count 1) {
                $myObject1 | add-member -type Noteproperty -Name LastBootTime -Value (Get-CIMInstance -Computername (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, “”) Win32_OperatingSystem).LastBootUptime
                $myObject1 | add-member -type Noteproperty -Name UptimeinHours -Value ((get-date) – (Get-CIMInstance -Computername (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, “”) Win32_OperatingSystem).LastBootUpTime).Totalhours
                }
                else {
                $myObject1 | add-member -type Noteproperty -Name LastBootTime -Value “Not Online”
                $myObject1 | add-member -type Noteproperty -Name UptimeinHours -Value “Not Online”
                }

                That way, if they were not online, they would still report the other info. But in the output, all are saying ‘Not Online’, though I know many of them are online.

    1. Line number 74 already there, you can change Server to Hostname there and make relevant changes for the same properties in rest script

      $myObject1 | add-member -type Noteproperty -Name Server -Value (($object1 | select -ExpandProperty FullDomainName) -replace $DomainName, “”)

Leave a reply to Mali Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.