PowerShell Example: Automate to Compliance
Goal
Provide an example PowerShell script that scans Windows target machines and deploys all missing patches.
Overview
The script will automatically repeat the following steps until no more patches are detected as missing and your machines are in full compliance.
Product levels are not supported by this script.
- Scan for patches using the specified patch scan template.
- Attempt to deploy all missing patches according to the specified patch deployment template.
- Reboot as directed.
The script is run from a PowerShell command prompt such as PowerShell ISE or a similar tool with Run as Administrator privileges. It can be run from the Security Controls console or remotely from a server or workstation. If the script is run remotely, proper credentials must be provided.
See the Description section of the script for examples of how to execute the script and to view a list of the parameters that are available.
Example Script
################################################################################# # # DISCLAIMER: EXAMPLE ONLY # # Execute this example at your own risk. The console, target machines and # databases should be backed up prior to executing this example. Ivanti does # not warrant that the functions contained in this example will be # uninterrupted or free of error. The entire risk as to the results and # performance of this example is assumed by the person executing the example. # Ivanti is not responsible for any damage caused by this example. # ################################################################################# <# .SYNOPSIS Iteratively scan and deploy to target Windows machine(s) until no patches are detected as 'Missing'. .DESCRIPTION Iteratively scan and deploy to target Windows machine(s) until no patches are detected as 'Missing'. This script assumes that it will be running in the context of an administrative user on the same system as security controls. To run remotely (on a system other than the console) requires the additional configuration of the issuing cert of the console in the trusted root store along with supplying a session credential. See https://help.ivanti.com/iv/help/en_US/isec/API/Topics/Setup%20Script.htm for more info. It also assumes that connection credentials have been set for the machine or at the machine group level. .PARAMETER MachineGroup Machine group to be patched. .PARAMETER MachineName Machine name to be patched. .PARAMETER ConsoleName Name of console. .PARAMETER Port Port used for Security Controls REST API calls. Default is 3121. .PARAMETER ScanTemplateName Scan template. Windows only. Default to Security Patch Scan. .PARAMETER DeploymentTemplateName Deployment template. Windows only. Default to Standard. .PARAMETER AttemptLimit Max attempts. Default to 5. .PARAMETER TimeoutLimitInMinutes Operation timeout limit to wait for a single scan/deployment in minutes. Default to 180 minutes. .PARAMETER Credential Credential used to connect to console REST Api. If omitted, will fall back to UseDefaultCredentials. .PARAMETER LogFileFolder Log file folder. .PARAMETER OperationName Name used for the scan, deployment and log file. .EXAMPLE Running the script on a Console .\Install-AllMissingPatches.ps1 -MachineGroup "windows-machines" .EXAMPLE Running the script on a Console using a custom scan template .\Install-AllMissingPatches.ps1 -MachineGroup "windows-machines" -ScanTemplateName "sql-server-scan-template" .EXAMPLE Running the script on an Agent/Client (Credential is required and the issuing cert of the console must be in the trusted root store, see https://help.ivanti.com/iv/help/en_US/isec/API/Topics/Setup%20Script.htm for more info.) $Credential=Get-Credential .\Install-AllMissingPatches.ps1 -MachineGroup "windows-machines" -ConsoleName "console" -Credential $Credential #> [CmdletBinding(DefaultParameterSetName = 'MachineGroup')] param ( [Parameter(Mandatory=$true, ParameterSetName="MachineGroup", HelpMessage="Machine group to be patched.")] [String] $MachineGroupName, [Parameter(Mandatory=$true, ParameterSetName="MachineName", HelpMessage="Machine name to be patched.")] [String] $MachineName, [Parameter(Mandatory=$false, HelpMessage="Name of console. Default to current machine. ")] [String] $ConsoleName=$null, [Parameter(Mandatory=$false, HelpMessage="Port used for Security Controls REST API calls. Default is 3121.")] [ValidateRange(0, 65353)] [UInt32] $Port=3121, [Parameter(Mandatory=$false, HelpMessage="Scan template. Default to Security Patch Scan.")] [String] $ScanTemplateName="Security Patch Scan", [Parameter(Mandatory=$false, HelpMessage="Deployment template. Default to Standard.")] [String] $DeploymentTemplateName="Standard", [Parameter(Mandatory=$false, HelpMessage="Max attempts. Default to 5.")] [UInt32] $DeployAttemptLimit=5, [Parameter(Mandatory=$false, HelpMessage="Operation timeout limit to wait for a single scan/deployment in minutes. Default to 180 minutes.")] [ValidateRange(5, 1440)] [UInt32] $TimeoutLimitInMinutes=180, [Parameter(Mandatory = $false, HelpMessage="Credential used to access remote machine. If omitted, will fall back to UseDefaultCredentials.")] [PSCredential] $Credential, [Parameter(Mandatory = $false, HelpMessage="Log file folder.")] [ValidateScript( { Test-Path -Path $_ })] [String]$LogFileFolder, [Parameter(Mandatory=$false, HelpMessage="Name used for the scan, deployment and log file.")] [String] $OperationName ) #Helper function to write logs where needed function Log-Debug { param ( [string] $LogPhrase ) #Add timestamp to beginning of any string filter timestamp {"$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - $_"} Write-Debug ($LogPhrase | timestamp) } function Log-Host { param ( [string] $LogPhrase ) #Add timestamp to beginning of any string filter timestamp {"$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - $_"} Write-Host ($LogPhrase | timestamp) } Add-Type -AssemblyName "System.Security" | Out-Null # Adds a session credential which is used to authenticate as an administrator with capabilities of decrypting the endpoint credentials. # The existing session credential will be overwritten. # This credential is an administrator on the console machine. This credential is used to: # 1) Authenticate to the console machine REST API. # 2) Used as the session credential on the console. # 3) Used as the credential that starts the scan and deployments. function Add-SessionCredential { Param ( [String]$consoleName, [Int32]$port, [PSCredential]$authenticationAndSessionCredential ) $uri = "https://${consoleName}:${port}/st/console/api/v1.0/sessioncredentials" try { Invoke-RestMethod -Uri $uri -Method 'Delete' -Credential $authenticationAndSessionCredential } catch [Exception] { $ex = $_.Exception if ($ex.Response.StatusCode -ne 404) { throw } } try { $credentialRequest = Create-CredentialRequest -friendlyName "unused" -userName "unused" -password $authenticationAndSessionCredential.Password -consoleName $consoleName -port $port -authenticationAndSessionCredential $authenticationAndSessionCredential $sessionCredentialBody = $credentialRequest.Password | ConvertTo-Json -Depth 99 Invoke-RestMethod -Uri $uri -Method 'Post' -Body $sessionCredentialBody -Credential $authenticationAndSessionCredential -ContentType 'application/json' | Out-Null } catch [Exception] { $ex = $_.Exception if ($ex.Response.StatusCode -ne 409) { throw } } } # Creates and returns a secure credential body encrypted with AES algorithm and RSA certificate. # The purpose of this is to prevent password strings from ever sitting on the garbage collected heap in powershell or .NET memory space. # Easier solution if passwords sitting in the garbage collected heap is not a problem: $body = @{ "userName" = $userName; "name" = $friendlyName; "clearText" = "PasswordHere"; } # Passwords are ALWAYS transmitted over a secure TLS channel regardless of the encryption chosen. function Create-CredentialRequest { param ( [String]$friendlyName, [String]$userName, [SecureString]$password, [String]$consoleName, [Int32]$port, [PSCredential]$authenticationAndSessionCredential ) $body = @{ "userName" = $userName; "name" = $friendlyName; } $bstr = [IntPtr]::Zero; try { # Create an AES Session key. $algorithm = 'Aes' $aes = [System.Security.Cryptography.SymmetricAlgorithm]::Create($algorithm); $keyBytes = $aes.Key; # Encrypt the session key with the console cert $encryptedKey = Encrypt-RSAConsoleCert -toEncrypt $keyBytes -authenticationAndSessionCredential $authenticationAndSessionCredential -consoleName $consoleName -port $port $session = @{ "algorithmIdentifier" = $algorithm; "encryptedKey" = [Convert]::ToBase64String($encryptedKey); "iv" = [Convert]::ToBase64String($aes.IV); } # Encrypt the password with the Session key. $cryptoTransform = $aes.CreateEncryptor(); # Copy the BSTR contents to a byte array, excluding the trailing string terminator. $size = [System.Text.Encoding]::Unicode.GetMaxByteCount($password.Length - 1); $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) $clearTextPasswordArray = New-Object Byte[] $size [System.Runtime.InteropServices.Marshal]::Copy($bstr, $clearTextPasswordArray, 0, $size) $cipherText = $cryptoTransform.TransformFinalBlock($clearTextPasswordArray, 0 , $size) $serializedPossword = @{ "cipherText" = $cipherText; "protectionMode" = "SessionKey"; "sessionKey" = $session } } finally { # Ensure All sensitive byte arrays are cleared and all crypto keys/handles are disposed. if ($clearTextPasswordArray -ne $null) { [Array]::Clear($clearTextPasswordArray, 0, $size) } if ($keyBytes -ne $null) { [Array]::Clear($keyBytes, 0, $keyBytes.Length) } if ($bstr -ne [IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } if ($cryptoTransform -ne $null) { $cryptoTransform.Dispose() } if ($aes -ne $null) { $aes.Dispose() } } $body.Add("password", $serializedPossword) return $body } # The encryption process used within the function 'Create-CredentialRequest'. Encrypted with protects console certificate. # The encrypted rsa public key is returned. function Encrypt-RSAConsoleCert { param ( [Byte[]]$toEncrypt, [String]$consoleName, [Int32]$port, [PSCredential]$authenticationAndSessionCredential ) try { $certResponse = Invoke-RestMethod $Uris.CertificateConsole -Method GET -Credential $authenticationAndSessionCredential [Byte[]] $rawBytes = ([Convert]::FromBase64String($certResponse.derEncoded)) $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(, $rawBytes) $rsaPublicKey = $cert.PublicKey.Key; $encryptedKey = $rsaPublicKey.Encrypt($toEncrypt, $true); return $encryptedKey } catch [Exception] { $ex = $_.Exception if ($ex.Response.StatusCode -eq 401) { if ($cert -ne $null) { $cert.Dispose() } Write-Error "The session credential was invalid." exit } else { throw } } finally { if ($cert -ne $null) { $cert.Dispose() } } } #loops through a Get request when multiple pages exist and returns list. Will stop when requested count is met. function Get-PaginatedResults { param ( [String]$uri, [Int]$maxcount ) $entireList = [System.Collections.ArrayList]@() $nextUri = $uri do { Log-Debug "Get-PaginatedResults uri: $nextUri" Log-Debug "Get-PaginatedResults maxcount: $maxcount" if ($Credential -ne $null) { $result = Invoke-RestMethod -Method Get -Uri $nextUri -Credential $Credential } else { $result = Invoke-RestMethod -Method Get -Uri $nextUri -UseDefaultCredentials } $result.value | Foreach-Object { $entireList.Add($_) } $nextUri = $result.links.next.href if ($maxcount) { if($entireList.length -ge $maxcount) { return $entireList } } } until ($null -eq $nextUri) return $entireList } #will wait until an operation is marked as not "Running" function Wait-Operation { param( [String] $operationLocation, [Int32] $timeoutMinutes ) Log-Debug "Run Wait-Operation with operationLocation=$operationLocation and timeoutMinutes=$timeoutMinutes" $startTime = [DateTime]::UtcNow if ($Credential -ne $null) { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -Credential $Credential } else { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -UseDefaultCredentials } #If operation is downloading patches, loop until operation moves to deployment if ($operationResult.operation -eq "PatchDownload") { Log-Debug "Downloading Patches" while ($operationResult.operation -eq "PatchDownload") { if ([DateTime]::UtcNow -gt $startTime.AddMinutes($timeoutMinutes)) { Log-Debug "The operation timed out after $($timeoutMinutes) minutes." return } Start-Sleep 5 if ($Credential -ne $null) { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -Credential $Credential } else { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -UseDefaultCredentials } Log-Debug "Operation Result: $operationResult" } } # Loop on child of operation until isComplete is True if ($operationResult.operation -eq "PatchDeployment") { Log-Debug "Beginning Deployment" if ([String]::IsNullOrWhiteSpace($operationResult.resourceLocation)) { Log-Debug "Invalid ResourceLocation, Abort Deployment" return } if ($Credential -ne $null) { $deploymentstatus = Invoke-RestMethod -Uri $operationResult.resourceLocation -Credential $Credential } else { $deploymentstatus = Invoke-RestMethod -Uri $operationResult.resourceLocation -UseDefaultCredentials } Log-Debug "Deployment Status: $deploymentstatus" while ($deploymentstatus.isComplete -eq $False) { if ([DateTime]::UtcNow -gt $startTime.AddMinutes($timeoutMinutes)) { Log-Debug "The operation timed out after $($timeoutMinutes) minutes." return } Start-Sleep 5 if ($Credential -ne $null) { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -Credential $Credential } else { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -UseDefaultCredentials } Log-Debug "Operation Result Status: $($operationResult.Status)" if ($Credential -ne $null) { $deploymentstatus = Invoke-RestMethod -Uri $operationResult.resourceLocation -Credential $Credential } else { $deploymentstatus = Invoke-RestMethod -Uri $operationResult.resourceLocation -UseDefaultCredentials } Log-Debug "Deployment Status: $deploymentstatus" } } # If operation is not downloading, or deploying, default to loop on operation "Running" while ($operationResult.Status -eq 'Running') { if ([DateTime]::UtcNow -gt $startTime.AddMinutes($timeoutMinutes)) { Log-Debug "The operation timed out after $timeoutMinutes minutes." return } Start-Sleep 5 if ($Credential -ne $null) { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -Credential $Credential } else { $operationResult = Invoke-RestMethod -Uri $operationLocation -Method Get -UseDefaultCredentials } Log-Debug "Operation Result: $($operationResult.Status)" } #Write out the full operation status after completion Log-Debug "Final Operation Result: $($operationResult.Status)" return $operationResult } #Kick off scan with a machine group, patch template function Scan-Operation { param( [Parameter(Mandatory=$true, ParameterSetName="machineGroupId")] [Int32[]] $machineGroupId, [Parameter(Mandatory=$true, ParameterSetName="machineName")] [String[]] $scanMachineName, [Guid] $templateId ) if ($machineGroupId) { $body = @{ machineGroupIds = @( $machineGroupId ); Name = $OperationName; TemplateId = $templateId; } | ConvertTo-Json -Depth 99 } if ($scanMachineName) { $body = @{ endpointNames = @( $scanMachineName ); Name = $OperationName; TemplateId = $templateId; } | ConvertTo-Json -Depth 99 } if ($Credential -ne $null) { $scanOperation = Invoke-WebRequest -Uri $Uris.PatchScans -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing -Credential $Credential } else { $scanOperation = Invoke-WebRequest -Uri $Uris.PatchScans -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing -UseDefaultCredentials } Wait-Operation $scanOperation.headers['Operation-Location'] $TimeoutLimitInMinutes | Out-Null Log-Debug $scanOperation $scan = (ConvertFrom-Json $([String]::new($scanOperation.Content))) if ($Credential -ne $null) { $foundScan = Invoke-RestMethod -Uri $scan.links.self.href -Credential $Credential } else { $foundScan = Invoke-RestMethod -Uri $scan.links.self.href -UseDefaultCredentials } Log-Debug $foundScan return $foundScan } #Kick off deployment with a scan result and deployment template id function Deployment-Operation { param( [Guid] $scanId, [Guid] $deploymentTemplateId ) $body = @{ scanID = $scanId; templateID = $deploymentTemplateId } | ConvertTo-Json -Depth 99 if ($Credential -ne $null) { $deploymentOperation = Invoke-WebRequest -Uri $Uris.PatchDeployments -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing -Credential $Credential } else { $deploymentOperation = Invoke-WebRequest -Uri $Uris.PatchDeployments -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing -UseDefaultCredentials } Log-Debug "$($deploymentOperation.headers['Operation-Location'])" Wait-Operation $deploymentOperation.headers['Operation-Location'] $TimeoutLimitInMinutes | Out-Null if ($Credential -ne $null) { $foundDeployment = Invoke-RestMethod -Uri $deploymentoperation.headers.'Operation-Location' -Method Get -Credential $Credential } else { $foundDeployment = Invoke-RestMethod -Uri $deploymentoperation.headers.'Operation-Location' -Method Get -UseDefaultCredentials } return $foundDeployment } ########################################################################## # Start Script ########################################################################## #Get time to return full elapsed time $startTime = (Get-Date) if ($ConsoleName -eq $null -or $ConsoleName -eq "") { $ConsoleName = $env:computername if ($env:userdnsdomain -ne $null) { #It may not work for console disconnected from domain #$ConsoleName = "$ConsoleName.$env:userdnsdomain" } } if ([String]::IsNullOrWhiteSpace($OperationName)) { if ($MachineGroupName) { $OperationName = "Install-AllMissingPatches-$MachineGroupName" } else { $OperationName = "Install-AllMissingPatches-$MachineName" } } #The variables set above are passed into the URIs below to set each unique API URI $Uris = @{ Credentials = "https://$ConsoleName`:$Port/st/console/api/v1.0/credentials" CertificateConsole = "https://$ConsoleName`:$Port/st/console/api/v1.0/configuration/certificate" MachineGroups = "https://$ConsoleName`:$Port/st/console/api/v1.0/machinegroups" Operations = "https://$ConsoleName`:$Port/st/console/api/v1.0/operations" PatchDeployments = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/deployments" PatchDeployTemplates = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/deploytemplates" PatchDownloads = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/downloads" PatchDownloadsScansPatch = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/downloads/scans" PatchGroups = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/groups" PatchScans = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/scans" PatchScanMachines = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/scans/{0}/machines" PatchScanMachinesPatches = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/scans/{0}/machines/{1}/patches" PatchScanTemplates = "https://$ConsoleName`:$Port/st/console/api/v1.0/patch/scanTemplates" } if ($LogFileFolder) { Start-Transcript -Path "$LogFileFolder\$OperationName.log" -Append -Force } try { if ($Credential -ne $null) { Add-SessionCredential -authenticationAndSessionCredential $Credential -consoleName $ConsoleName -port $Port } #Set TLS protocol to TLS1.2 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType] 'Tls12' #Find all objects that match each name if (($_foundScanTemplate = Get-PaginatedResults $Uris.PatchScanTemplates | Where-Object { $_.Name -eq $ScanTemplateName }) -eq $null) { throw "No patch template found with specified name." } if (($_foundDeploymentTemplate = Get-PaginatedResults $Uris.PatchDeployTemplates | Where-Object { $_.Name -eq $DeploymentTemplateName }) -eq $null) { throw "No deployment template found with specified name." } if ($MachineGroupName) { if (($_foundMachineGroup = Get-PaginatedResults $Uris.MachineGroups | Where-Object { $_.Name -eq $MachineGroupName }) -eq $null) { throw "No machine group found with specified name." } } #Run initial scan Log-Host "Starting Scan..." if ($MachineGroupName) { $scanResult = Scan-Operation -machineGroupId $_foundMachineGroup.id -templateId $_foundScanTemplate.id } else { $scanResult = Scan-Operation -scanMachineName $MachineName -templateId $_foundScanTemplate.id } if ($scanResult.links.machines.href) { $machinestatus = Get-PaginatedResults $scanResult.links.machines.href $patchCount = ($machinestatus.missingpatchcount | Measure-Object -sum).sum $failedMachines = (($machinestatus | Where-Object {$null -ne $_.name} | Where-Object { $_.errorNumber -ne 0 }) | Measure-Object).count } else { $patchCount = 0 $failedMachines = 0 } $attempts = 0 $scannedMachines = (($machinestatus | Where-Object {$null -ne $_.name} | Where-Object { $_.errorNumber -eq 0 }) | Measure-Object).count if ($scannedMachines -ne 0) { Log-Host "$patchCount Total Patches Missing" } Log-Host "$failedMachines Machines Failed Scan" if ($scannedMachines -eq 0) { #Nothing to do } elseif ($patchcount -eq 0) { Log-Host "No Missing Patches" } else { do { if ($attempts -eq $DeployAttemptLimit) { Log-Host "Exceeded Maximum Number of Deployment Attempts" $endTime = (Get-Date) $elapsedTime = $endTime-$startTime $formatTime = 'Duration: {0:mm} min {0:ss} sec' -f $elapsedTime Log-Host "$formatTime" return } $attempts++ Log-Host "Deployment Attempt#: $attempts of $DeployAttemptLimit" Log-Host "Starting Deployment..." Deployment-Operation $scanResult.id $_foundDeploymentTemplate.id | Out-Null Log-Host "Starting Scan..." if ($MachineGroupName) { $scanResult = Scan-Operation -machineGroupId $_foundMachineGroup.id -templateId $_foundScanTemplate.id } else { $scanResult = Scan-Operation -scanMachineName $MachineName -templateId $_foundScanTemplate.id } if ($scanResult.links.machines.href) { $machinestatus = Get-PaginatedResults $scanResult.links.machines.href $patchCount = ($machinestatus.missingpatchcount | Measure-Object -sum).sum $failedMachines = (($machinestatus | Where-Object {$null -ne $_.name} | Where-Object { $_.errorNumber -ne 0 }) | Measure-Object).count $scannedMachines = (($machinestatus | Where-Object {$null -ne $_.name} | Where-Object { $_.errorNumber -eq 0 }) | Measure-Object).count } else { $patchCount = 0 $failedMachines = 0 } if ($scannedMachines -ne 0) { Log-Host "$patchCount Total Patches Missing" } Log-Host "$failedMachines Machines Failed Scan" } until($patchCount -eq 0) if (($patchcount -eq 0) -and ($scannedMachines -ne 0)) { Log-Host "No Missing Patches After $attempts Attempts" } } $endTime = (Get-Date) $elapsedTime = $endTime-$startTime $formatTime = 'Duration:{0:hh} hr {0:mm} min {0:ss} sec' -f $elapsedTime Log-Host "$formatTime" } catch { #all error end up here, if they are breaking errors Throw $_ } finally { if ($EnableLogging) { Stop-Transcript } } ########################################################################## # End Script ##########################################################################