Patch Installation using PowerShell, VBScript and PSExec

In my current environment, I am one of many people in our shop that carry the same task as many of you. That task is to patch our systems to ensure we keep ourselves up to date on the latest security updates. This can be done many different ways from manually downloading and installing patches, setting deadlines in WSUS to complete the installations, managing the installations via Group Policy to name a few of the possible scenarios.  Another way that I will show you combines a melting pot of technologies using PowerShell, VBScript and PSExec.exe to completely automate this task. If you have seen my other function, Get-PendingUpdates and its associated blog posting here, then I would consider this the partner module to that.

VB…What?!?

Ok, you might be asking me: “Boe, why would we use this old legacy scripting language when we have the awesomeness that is PowerShell?” Good question! The answer to that is that while we do have PowerShell which makes our jobs and life soooo much easier, most of us simply do not have it installed on every system, or some systems older and cannot have PowerShell installed on them. Hence, we have VBscript which bridges that gap and gives us a scripting language that works on legacy systems all the way up to our Win2K8R2 systems. The trick to this is that instead of having a vbscript file to copy each time, I simply use a here-string to contain the code and then use Out-File to write the vbscript to the remote machine. This also makes this code more portable as you only have one file to deal with as opposed to multiple files.

The Module

I decided to go with a module so I can only export the one function I need while leaving the other functions that do the background work hidden. With that in mind, when you copy the code you must save it with a .psm1 extension and use the Import-Module cmdlet. I wrote the module so that you can supply a collection of computers if needed and it will do all of the processing of each system itself. You will need to download PSExec from here and place it in the same directory from where ever you are calling the function from. The Install-Patches function is the only visible function that you have available to run, but there are a total of 3 functions that are available within the module (Install-Patches,Create-UpdateVBS and Format-InstallPatchLog). It will then call the Create-UpdateVBS function to use the hardcoded Here-String of the vbs code and use the Out-File cmdlet to create the required Update.vbs file on each system.

I cannot take credit for most of this vbscript as it was created by Microsoft and is available to view from their site here. I say most because I did make some adjustments to make it more PowerShell friendly such as writing out the log file to a CSV instead of a text file. I also made some adjustments such as accepting a EULA if needed for an update prior to installing.  I also took out the downloading of updates as well since they will have already been downloaded to the machine from WSUS (or downloaded if set up to do so on your computer to do so automatically).

After the file has been created successfully, the Install-Patches function continues on and uses PSExec to remotely (or not remotely if running against your local machine) install the patches. I chose this route as I have not found a feasible way to remotely install patches. Using a WMI method with the Win32_Process and Create method does not work and neither does PowerShell remoting. This is a “by design” feature of the COM object and does not look to be changed any time soon. PSexec is my best approach at working around this obstacle. After the Update.vbs script a CSV file is created either showing what patches were installed or showing that no patches were installed for one reason or another.

After PSExec runs, I then check the value of $LASTEXITCODE, which tells you the return code of the application that ran with a 0 being successful. $LASTEXITCODE is described as:

Contains the exit code of the last win32 executable execution

 

If no issues are encountered with PSExec, then the next function that is called is Format-InstallPatchLog which takes the created CSV file and makes a couple of adjustments to the log file before presenting it as the output object.

Module in Action

Ok, enough talk about the module itself and now onto the actual module in action. The first thing I do is import my module into the PowerShell console using the following line of code:

Import-Module Install-Patches.psm1 -Force

image

I use the –Force switch because I already had the module imported and wanted to “overwrite” the existing function. I say “overwrite” because if you run the command while with –Verbose, you will see that it actually removes the existing function before adding the new function.

The next step is to make sure that you are in the same directory as PSExec.exe to be sure that the function will properly, otherwise it will throw a message and halt the function.

image

Ok, lets actually run the function against a remote system and see what happens.

Install-Patches -Computername DC1

image

As you can see, the PSExec operation took place against DC1 as expected and the reporting object was also returned which is good. Looking at this, we can see that the InstallResultCode is reporting that a reboot is required to complete the patch installation. This is by design that the installation does not automatically reboot the system. It is important to keep in mind that if there are more patches, then it will take longer to run through the installation of each patch. This function does accept a collection of systems and also accepts those from the pipeline as well which allows you some flexibility in how you would like to run the command.

There may be instances when a result code is not displayed or says “Unable to determine Result Code”. This means that the result code returned from the script could not be decoded by the function. That is not to say that the code can never be figured out, it is that I chose to not include the hundreds of possible codes in the script. If you would like a listing of all of the codes to search from, you can find that here.

With that, you can see how this might prove useful to you based on your environment. You could pipe the results into a CSV file if you wanted something to email folks with. This also makes it easy to determine if any patches failed or which systems would need to be rebooted after the installations. You can even use Get-PendingUpdates as an extra measure to make sure no other patches have snuck in and need to be installed.

Code

As always, the code is available to copy from either here or from the following locations:

Script Repository

PoshCode

<#
Save this with a .PSM1 extension and use Import-Module to properly load the functions.
ie: Install-Patches.psm1
Import-Module Install-Patches.psm1
#>
#Validate user is an Administrator
Write-Verbose "Checking Administrator credentials"
If (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(`
    [Security.Principal.WindowsBuiltInRole] "Administrator"))
{
    Write-Warning "You are not running this as an Administrator!`nPlease re-run this with an Administrator account."
    Break
}

Function Create-UpdateVBS {
Param ($computername)
#Create Here-String of vbscode to create file on remote system
$vbsstring = @"
ON ERROR RESUME NEXT
CONST ForAppending = 8
CONST ForWriting = 2
CONST ForReading = 1
strlocalhost = "."
Set oShell = CreateObject("WScript.Shell") 
set ofso = createobject("scripting.filesystemobject")
Set updateSession = CreateObject("Microsoft.Update.Session")
Set updateSearcher = updateSession.CreateupdateSearcher()
Set updatesToInstall = CreateObject("Microsoft.Update.UpdateColl")
Set searchResult = updateSearcher.Search("IsInstalled=0 and Type='Software'")
Set objWMI = GetObject("winmgmts:\\" & strlocalhost & "\root\CIMV2")
set colitems = objWMI.ExecQuery("SELECT Name FROM Win32_ComputerSystem")
	For Each objcol in colitems
		strcomputer = objcol.Name
	Next
set objtextfile = ofso.createtextfile("C:\" & strcomputer & "_patchlog.csv", True)
objtextfile.writeline "Computer,Title,KB,InstallResultCode"
If searchresult.updates.count = 0 Then
	Wscript.echo "No updates to install."
	objtextfile.writeline strcomputer & ",NA,NA,NA"
	Wscript.Quit
Else
For I = 0 To searchResult.Updates.Count-1
    set update = searchResult.Updates.Item(I)
	    If update.IsDownloaded = true Then
	       updatesToInstall.Add(update)	
	End If
Next
End If
err.clear
Wscript.Echo "Installing Updates"
Set installer = updateSession.CreateUpdateInstaller()
installer.Updates = updatesToInstall
Set installationResult = installer.Install()
	If err.number <> 0 Then
		objtextfile.writeline strcomputer & "," & update.Title & ",NA," & err.number
	Else		
		For I = 0 to updatesToInstall.Count - 1
		objtextfile.writeline strcomputer & "," & updatesToInstall.Item(i).Title & ",NA," & installationResult.GetUpdateResult(i).ResultCode 
		Next
	End If
Wscript.Quit
"@

Write-Verbose "Creating vbscript file on $computer"
Try {
    $vbsstring | Out-File "\\$computername\c$\update.vbs" -Force
    Return $True
    }
Catch {
    Write-Warning "Unable to create update.vbs!"
    Return $False
    }
}


Function Format-InstallPatchLog {
    [cmdletbinding()]
    param ($computername)
    
    #Create empty collection
    $installreport = @()
    #Check for logfile
    If (Test-Path "\\$computername\c$\$($computername)_patchlog.csv") {
        #Retrieve the logfile from remote server
        [array]$CSVreport = Import-Csv "\\$computername\c$\$($computername)_patchlog.csv"
        If ($csvreport[0].title -ne "NA") {
            #Iterate through all items in patchlog
            ForEach ($log in $CSVreport) {
                $temp = "" | Select Computer,Title,KB,InstallResultCode
                $temp.Computer = $log.Computer
                $temp.Title = $log.title.split('\(')[0]
                $temp.KB = ($log.title.split('\(')[1]).split('\)')[0]
                Switch ($log.InstallResultCode) {
                    1 {$temp.InstallResultCode = "No Reboot required"}
                    2 {$temp.InstallResultCode = "Reboot Required"}
                    4 {$temp.InstallResultCode = "Failed to Install Patch"}
                    "-2145124316" {$temp.InstallResultCode = "Update is not available to install"}
                    Default {$temp.InstallResultCode = "Unable to determine Result Code"}            
                    }
                $installreport += $temp
                }
            }
        Else {
            $temp = "" | Select Computer, Title, KB,InstallResultCode
            $temp.Computer = $computername
            $temp.Title = "NA"
            $temp.KB = "NA"
            $temp.InstallResultCode = "NA"  
            $installreport += $temp            
            }
        }
    Else {
        $temp = "" | Select Computer, Title, KB,InstallResultCode
        $temp.Computer = $computername
        $temp.Title = "NA"
        $temp.KB = "NA"
        $temp.InstallResultCode = "NA"  
        $installreport += $temp      
        }
    Write-Output $installreport
}

Function Install-Patches {
<#    
.SYNOPSIS    
    Install patches on a local or remote computer and generates a report.
.DESCRIPTION  
    Install patches on a local or remote computer and generates a report with status of installation.
.PARAMETER Computername  
    Name of the computer to install patches on.           
.NOTES    
    Name: Install-Patches 
    Author: Boe Prox  
    DateCreated: 19May2011   
        
.LINK    
    https://boeprox.wordpress.com  
.EXAMPLE    
    Install-Patches -Computername Server1
    
    Description
    -----------
    Installs patches on Server1 and displays report with installation status.

.EXAMPLE    
    Install-Patches -Computername Server1,Server2,Server3
    
    Description
    -----------
    Installs patches on Server1,Server2 and Server3 and displays report with installation status.    
#>
[cmdletbinding()]
Param(
    [Parameter(Mandatory = $True,Position = 0,ValueFromPipeline = $True)]  
    [string[]]$Computername
    )
Begin {
    If (-Not (Test-Path psexec.exe)) {
        Write-Warning "PSExec not in same directory as script!"  
        Break
        }
    }
Process {
    ForEach ($computer in $computername) {
        If ((Test-Connection -ComputerName $computer -Count 1 -Quiet)) {
            Write-Verbose "Creating update.vbs file on remote server."
            If (Create-UpdateVBS -computer $computer) {
                Write-Verbose "Patching computer: $($computer)"
                .\psexec.exe -accepteula -s -i \\$computer cscript.exe C:\update.vbs
                If ($LASTEXITCODE -eq 0) {
                    Write-Verbose "Successful run of install script!"
                    Write-Verbose "Formatting log file and adding to report"
                    Format-InstallPatchLog -computer $computer
                    }            
                Else {
                    Write-Warning "Unsuccessful run of install script!"
                    $report = "" | Select Computer,Title,KB,IsDownloaded,InstallResultCode
                    $report.Computer = $computer
                    $report.Title = "ERROR"
                    $report.KB = "ERROR"
                    $report.IsDownloaded = "ERROR"
                    $report.InstallResultCode = "ERROR" 
                    Write-Output $report
                    }
                }
            Else {
                Write-Warning "Unable to install patches on $computer"
                $report = "" | Select Computer,Title,KB,IsDownloaded,InstallResultCode
                $report.Computer = $computer
                $report.Title = "ERROR"
                $report.KB = "ERROR"
                $report.IsDownloaded = "ERROR"
                $report.InstallResultCode = "ERROR" 
                Write-Output $report
                }
            }
        Else {
            Write-Warning "$($Computer): Unable to connect!"
            } 
        } 
    }   
}
Export-ModuleMember Install-Patches
This entry was posted in powershell, scripts and tagged , , , . Bookmark the permalink.

3 Responses to Patch Installation using PowerShell, VBScript and PSExec

  1. Mike Erskine says:

    I know this is an old post however we had an admin leave and take all of his patch tools with him.
    Our environment is we are not connected to the internet…on purpose…don’t ask.
    Is it possible to run this from a CD/DVD/BlueRay with the patch repository on the same disk?
    Maybe even having the repository on a network share?

  2. Fabrizio Gatti says:

    Hello!

    Very useful! However, in case I have a list of computers I have to execute the script once for each machine. How can I make this automatic?

    Thanks
    Fabrizio.

  3. Matt says:

    Nice one Boe, I’ve been looking at something similar with wuinstall. I’ll be trying this out as well.

    Thanks,
    Matt

Leave a comment