Creating Safer PowerShell Functions

Make sure your functions are indeed "Advanced Functions", and add Risk Mitigation support to make your code safer to use.

When PowerShell initially surfaced, it came with Simple Functions: while you could use PowerShell code to add new commands, binary Cmdlets were much more powerful.

With PowerShell 2, the team added Advanced Functions which now support the same set of cmdlet features. The limited Simple Functions are still around, though, and with the attribute [CmdletBinding()] you make sure you get the powerful Advanced Functions instead of the limited Simple Functions.

Once you create Advanced Functions, you get a lot of features for free and on top, for example Risk Mitigation: PowerShell adds support for confirmation dialogs and simulation mode and a rich safety net for the user. So if you are writing PowerShell functions, check out the adjustments to make your code safer to use.

If you are completely new to attributes, you might want to read the primer first.

Enabling Advanced Functions

The attribute [CmdletBinding()] can be attached to param() blocks inside PowerShell functions and controls the fundamental behavior of your function. The by far most common use case is to turn a Simple Function into an Advanced Function. You may have stumbled across code like this:

function Out-PsoPdf
{
  [CmdletBinding()]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  # here is room for the actual code
  # it's empty for now
}

As you can see, [CmdletBinding()] is added to the param() block. But why? After all, the attribute is completely empty and defines no values in its parenthesis.

Simple and Advanced Functions

If a function uses at least one (any) attribute, PowerShell turns it into an Advanced Function and enables all of its features. The most visible change occurs in its parameters: Advanced Functions automatically add all the common parameters such as -Verbose and -ErrorAction. Simple Function only displays the parameters you defined in your param() block.

Test it: run the code above, then run Out-PsoPdf and look at its parameters. You’ll get all the common parameters like -ErrorAction and -Verbose for free.

The automatically added common parameters are fully functional. So when you call your function with -ErrorAction Ignore, then this setting will be passed on to all PowerShell commands in your function and suppress all errors. Likewise, if you use Write-Verbose in your code to output messages, they will be hidden unless the function is called with the common (switch) parameter -Verbose.

The truth is: -Verbose sets the default value for $VerbosePreference, whereas -ErrorAction sets the default value for $ErrorActionPreference.

Once you remove [CmdletBinding()] from the code above, you get a Simple Function, and when you run it, you’ll see only your own parameters that were defined inside param(). All common parameters are gone. They are a feature of Advanced Functions.

Any Attribute Enables Attributes

In fact, any attribute enables Advanced Functions. Once you start using [Parameter()] attributes to control parameter features, you get Advanced Functions, too, and technically, you could remove the empty attribute [CmdletBinding()]:

function Out-PsoPdf
{
  param
  (
    [Parameter(Mandatory)]
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  # here is room for the actual code
  # it's empty for now
}

It is good practice to always add [CmdletBinding()] to your param() block to ensure you create an Advanced Function, even if you are using other attributes and wouldn’t strictly need to.

There are many PowerShell features that only work with Advanced Functions, for example support for default parameter values (defined in $PSDefaultParameterValues). To not run into compatibility issues by accident, using [CmdletBinding()] routinely in your code is a good idea.

Available Values

[CmdletBinding()] can set a number of options. With one of the helper scripts from the primer article, these are the available values:

PS> $attributes.CmdletBinding

Name                    Type                                           
----                    ----                                           
PositionalBinding       System.Boolean                                 
DefaultParameterSetName System.String                                  
SupportsShouldProcess   System.Boolean                                 
SupportsPaging          System.Boolean                                 
SupportsTransactions    System.Boolean                                 
ConfirmImpact           System.Management.Automation.ConfirmImpact     
HelpUri                 System.String                                  
RemotingCapability      System.Management.Automation.RemotingCapability

The attribute values control four distinct areas:

  • Risk Mitigation: SupportsShouldProcess and ConfirmImpact control how risky your code is and whether it supports risk mitigation, adding the common parameters -WhatIf and -Confirm. This is what we cover today.

The remaining areas are covered in other parts of this series:

  • Paging: SupportsPaging automatically adds the common parameters -First, -Skip, and -IncludeTotalCount designed to handle large sets of data
  • Parameters: PositionalBinding and DefaultParameterSetName control how arguments are bound to your parameters
  • Information: HelpUri and RemotingCapability expose information such as the URL for help information, and the types of remoting techniques employed by your code

SupportsTransaction is obsolete as PowerShell no longer supports transactions (and never really has).

Risk Mitigation

PowerShell has a simple but powerful risk mitigation system built-in since version 1, however most PowerShell users today don’t seem to be fully aware of it. The idea is to keep users from accidentally causing havoc by running a powerful command with the wrong parameters.

Simulation and Confirmation

This can be done by either just simulating the code (-WhatIf) or by confirming each individual operation (-Confirm):

Stop-Service -Name a* -WhatIf

Risk mitigation makes sense only for commands that actually are risky. If a command only reads data, like Get-Date, there is not too much sense in simulating it, which is why -WhatIf and -Confirm are not available in every PowerShell command.

Put differently, when a command you are about to use has a -WhatIf and -Confirm parameter, there is one thing you know for sure: watch out, it could be risky to use.

While the code above is clearly is a career limiting move (CLM) on production servers, often risks aren’t as evident. That’s why PowerShell occasionally adds -Confirm to commands automatically, and you get unexpected (but sometimes life-saving) confirmation popups.

Automatic Confirmation

Both risk mitigation parameters are a great safety net provided you requested it before running a risky command. How do you know how risky a command is? That’s where PowerShells risk mitigation mode kicks in and helps to automatically detect risky commands before you run them.

Whether a command is considered super risky, and automatic confirmation dialogs are enabled, depends on two factors: the risk level of a command, and your personal risk level that you define:

  • Command: Each command author can declare the risk level (how much potential to mess things up) and choose from three levels: Low, Medium, and High. A risk level of High would be an operation that cannot be undone, i.e. deleting a user account or file.

  • You can declare your personal risk level (defined in $ConfirmPreference), which might depend on how experienced you are, and on which system you are currently working and how sensitive it is.

When a command has a risk level equal or higher to your risk level, PowerShell automatically enables the confirmation mode, just as if you had manually requested confirmation via the parameter -Confirm. The available risk levels are defined here:

[Enum]::GetNames([Management.Automation.ConfirmImpact])
None
Low
Medium
High

Just test it! This explains why some commands ask for confirmation while others don’t, and how you can control this:

# check the current confirm preference ('High' by default)
$ConfirmPreference

# run a command that has a certain risk and thus supports -Confirm
# (commands w/o -Confirm and -WhatIf do not participate in PowerShells
#  risk mitigation either because they do not change the system and have
#  no risk, or because the author failed to implement risk mitigation
#  support)
# Creating a new file is changing the system and supports risk mitigation:
New-Item -Path $env:temp\testfile.txt -Confirm

# try the same w/o explicitly asking for confirmation:
# (no confirmation dialog opens)
New-Item -Path $env:temp\testfile.txt

# cowardly change your ConfirmPreference to "Low" now:
$ConfirmPreference = 'Low'

# try again:
New-Item -Path $env:temp\testfile.txt

More or Less Confirmation Dialogs

You can control how often PowerShell pops up confirmation dialogs by changing $ConfirmPreference:

  • If you are working in a super sensitive environment or are new to PowerShell, you might want to set it to “Low”. Any risky command, even if it is just creating a new file, will now ask for confirmation.
  • If you want to run PowerShell code unattended and would like to dismiss any confirmation dialog that could block your execution, set it to “None”. Now PowerShell will never automatically pop up confirmations, even if your code bulk-deletes your entire Active Directory.
# enable confirmation for ALL PowerShell commands
$ConfirmPreference = "Low"

# NOTE: this is NOT a recommended default configuration
# You may now run into TONS of confirmation dialogs

While $ConfirmPreference controls the confirmation dialog, you can also ask PowerShell to be even more restrictive, and use -WhatIf for all risky commands: set $WhatIfPreference to $true. This may be a good child lock for PowerShell on production servers but you are also guaranteed to not get anything done anymore.

Kind of. Keep in mind that PowerShell risk mitigation (like -WhatIf) applies to PowerShell commands only: cmdlets and functions. Applications such as shutdown.exe or format.exe won’t care.

Discovering Risk Levels

Let’s change perspective and look at commands: how do they declare how risky they are? This is when [CmdletBinding()] becomes important. Binary cmdlet authors add this attribute to their code. So let’s start by reading the declared risk levels of binary cmdlets.

New-Item popped up a confirmation dialog in the previous example whenever $ConfirmPreference was set to Low, but did not with the default value of High. So the risk level of New-Item must either be Low or Medium.

Here is code that can read the values of the [CmdletBinding()] attribute from binary .NET assemblies. It reads the values for the command New-Item:

# get the cmdlet you are interested in
# (must be a cmdlet!)
$cmdletName = 'New-Item'
$cmd = Get-Command -Name $cmdletName -CommandType Cmdlet
# get the .NET type that implements the command:
$type = $cmd.ImplementingType
# get the [CmdletBinding()] attribute assigned to this type:
# (this attribute is defined as .NET type in the 
# namespace "System.Management.Automation", and the type name
# has "Attribute" as a suffix. Always true for all PS attributes)
$attribute = $type.GetCustomAttributes([System.Management.Automation.CmdletAttribute],$true)

# display the settings:
$attribute | Select-Object -Property *

The result looks like this:

NounName                : Item
VerbName                : New
DefaultParameterSetName : pathSet
SupportsShouldProcess   : True
SupportsPaging          : False
SupportsTransactions    : True

ConfirmImpact           : Medium

HelpUri                 : https://go.microsoft.com/fwlink/?LinkID=113353
RemotingCapability      : PowerShell
TypeId                  : System.Management.Automation.CmdletAttribute

New-Item has a ConfirmImpact of Medium. That makes sense: writing new files can have impact on a system but if it was done by accident, deleting the new file is entirely possible, so it’s just a medium risk.

Let’s check the ConfirmImpact of all cmdlets on your system and identify super risky cmdlets:

# get all cmdlets...
Get-Command -CommandType Cmdlet |
  # make sure the cmdlet module is loaded...
  Foreach-Object {
    Get-Command -CommandType Cmdlet -Name $_.Name
  } |
  # get the cmdletinfo attribute value:
  Foreach-Object {
    [PSCustomObject]@{
      Name = $_.Name
      ConfirmImpact = $_.ImplementingType.GetCustomAttributes([System.Management.Automation.CmdletAttribute],$true).ConfirmImpact
    }
  } |
  Sort-Object -Property ConfirmImpact -Descending |
  Out-GridView

The result is sorted by ConfirmImpact with cmdlets in the category High at top.

You may wonder why the code calls Get-Command twice. The first call lists all available cmdlets on your system, including those that come from modules that are currently not loaded into memory.

Next, the code calls Get-Command again individually for each found cmdlet because Get-Command works differently when you call it with a specific command name: now it makes sure the module for the command is loaded into memory. Only now can you access properties like ImplementingType that is populated only when the module is actually loaded into memory.

Declaring “ConfirmImpact”

If your PowerShell functions do something risky and change the system in any way, it is time to add support for risk levels. This starts by declaring how risky your function really is:

function Out-PsoPdf
{
  [CmdletBinding(ConfirmImpact='Low')]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  Write-Host "Doing Something" -ForegroundColor Green
}

The attribute uses ConfirmImpact to declare the risk level. Run the code, then run the function Out-PsoPdf. It outputs some green text.

Next, change ConfirmImpact to Low, and run the code again. Then, run Out-PsoPfdagain. Oops, no change! The green text shows again! Can you guess what’s wrong? If not, see below.

Adding Support for Risk Mitigation

Declaring a risk level for your code is nice but useless if you don’t also supply a safety net for your code in case of emergency. Or put differently, a command needs to support -WhatIf and -Confirm or else PowerShell can’t open a safety net for you.

So ConfirmImpact should always be combined with SupportsShouldProcess which adds the common parameters -WhatIf and -Confirm:

function Out-PsoPdf
{
  [CmdletBinding(ConfirmImpact='High', SupportsShouldProcess)]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  Write-Host "Doing Something" -ForegroundColor Green
}

This still won’t do much good because when you manually call -Confirm (which is now present thanks to SupportsShouldProcess), your function code doesn’t tell PowerShell what to do:

Out-PsoPdf -Confirm

Adding Confirmation Handlers

To support risk mitigation, you need to ask PowerShell whether it is ok to execute risky code or not. Then, use one or more conditions to execute your risky code only if *PowerShells risk mitigation system gave its ok:

function Out-PsoPdf
{
  [CmdletBinding(ConfirmImpact='High', SupportsShouldProcess)]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  # ask PowerShell whether code should execute:
  $shouldProcess = $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "something insane")
    
  # this will ALWAYS execute
  "ConfirmPreference: $ConfirmPreference"
  "WhatIfPreference:  $WhatIfPreference"
  
  # use a condition wherever appropriate to determine whether
  # code should execute:
  if ($shouldProcess)
  {
    Write-Host "Executing!" -ForegroundColor Green
  }
  else
  {
    Write-Host "Skipping!" -ForegroundColor Red
  }
  
  # any code outside will always execute
  "Done."
  
  # use conditions as often as appropriate
  # since you use the stored result in $shouldProcess,
  # there are no more annoying confirmation popups:
  if ($shouldProcess)
  {
    Write-Host "Executing again!" -ForegroundColor Green
  }
  else
  {
    Write-Host "Skipping again!" -ForegroundColor Red
  }
}

Now, Out-PsoPdf fully supports the risk mitigation system. It declared its own risk level of High:

  • When $ConfirmPreference is set to High (which is the default setting), PowerShell automatically asks for confirmation, and when the user accepts, $PSCmdlet.ShouldProcess() returns $true, else $false. So depending on what the user choice was, you see green or red text output.
  • When the user manually adds -Confirm, the confirmation dialog works in the same way
  • When the user manually adds -WhatIf, $PSCmdlet.ShouldProcess() returns $false, and you see red text output.

There are a couple of important gotchas:

  • Whenever your code starts a new distinct action, it should call $PSCmdlet.ShouldProcess() and submit a meaningful description text so the user gets asked per action.
  • Whenever you call $PSCmdlet.ShouldProcess(), make sure you save the result in a variable and reuse the result rather than calling $PSCmdlet.ShouldProcess() again for the same action. Each time you call $PSCmdlet.ShouldProcess(), a confirmation dialog may pop up.

Last Touches (and Gotchas)

It is important to understand how the risk mitigation system affects other risky commands that you may be using inside your function, or else you might run into gotchas and a plethora of unwanted confirmation dialogs.

Understanding the Problem

To fully understand the risk mitigation system, grab a fresh coffee, and chew on this code:

function Out-PsoPdf
{
  [CmdletBinding(ConfirmImpact='Medium', SupportsShouldProcess)]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  # ask PowerShell whether code should execute:
  $shouldProcess = $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "something extremely risky")
    
  # this will ALWAYS execute
  "ConfirmPreference: $ConfirmPreference"
  "WhatIfPreference:  $WhatIfPreference"
  
  # use a condition wherever appropriate to determine whether
  # code should execute:
  if ($shouldProcess)
  {
    New-Item -Path $env:temp\test.txt -Force
    New-Item -Path $env:temp\test.txt -Force
    Write-Host "Executing!" -ForegroundColor Green
  }
  else
  {
    New-Item -Path $env:temp\test.txt -Force
    Write-Host "Skipping!" -ForegroundColor Red
  }
}

Out-PsoPdf has a declared risk level of Medium. Make sure your preference is set to the default High:

$ConfirmPreference = 'High'

Then run the code above, and run Out-PsoPdf. The function shows a green message and executes. That’s expected because its risk level was below the risk level in $ConfirmPreference.

Now change your preference to Medium:

$ConfirmPreference = 'Medium'

Repeat the steps. Now you see a confirmation dialog with your own confirmation message, triggered by the call to $PSCmdlet.ShouldProcess(). That’s expected because the risk level of your function (Medium) is now equal or above the theshold in $ConfirmPreference (Medium). Click Yes.

Now you see yet another confirmation dialog, triggered by New-Item, and when you click Yes, yet another confirmation dialog opens, triggered by the other instance of New-Item. That is not expected: your conditional code should be an atom. Either the user wants to run it, or not.

What’s worse: any command you use plays entirely by its own rules, so it responds to its own thresholds and ConfirmImpact values. Depending on $ConfirmPreference, your function could pop up none, one, or any number of confirmation dialogs.

And it becomes even stranger: when the user starts to use -Confirm and -WhatIf manually, PowerShell actually changes the preference values which are in turn picked up by any command you use inside your function. Try this:

Out-PsoPdf -Confirm

A confirmation dialog opens. Click Yes. The confirmation opens twice more. Click Yes each time. You now got the green output, and when you look at the preference variables, they changed:

PS> Out-PsoPdf -Confirm
ConfirmPreference: Low
WhatIfPreference:  False

Because the user specified -Confirm, PowerShell temporarily changed $ConfirmPreference to Low (ensuring all commands with any risk level trigger confirmation).

The reason why PowerShell behaves in this strange way is because it represents the Poor Man’s Risk Mitigation: only by enabling -WhatIf and -Confirm, you get automagical risk mitigation by forwarding the settings to all commands used inside your function.

You can end up with a lot of unwanted and redundant dialogs, though, so the Smart Man’s Risk Mitigation calls $PSCmdlet.ShouldProcess() and controls code execution. To fully control it, you need to reset the preference variables (see below).

Reset Preference Variables

Once you use $PSCmdlet.ShouldProcess() and control yourself what code should execute when, you should also reset some of the preference variables to make sure all the other commands you use act accordingly:

function Out-PsoPdf
{
  [CmdletBinding(ConfirmImpact='Medium', SupportsShouldProcess)]
  param
  (
    [String]
    $Path,
  
    [int]
    $Timeout = 5,
    
    [switch]
    $Show
  )
  
  # ask PowerShell whether code should execute:
  $shouldProcess = $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "something insane")
  
  # turn off automatic confirmation dialogs for all commands
  $ConfirmPreference = 'None'
  
  # use a condition wherever appropriate to determine whether
  # code should execute:
  if ($shouldProcess)
  {
    New-Item -Path $env:temp\test.txt -Force
    New-Item -Path $env:temp\test.txt -Force
    Write-Host "Executing!" -ForegroundColor Green
  }
  else
  {
    New-Item -Path $env:temp\test.txt -Force
    Write-Host "Skipping!" -ForegroundColor Red
  }
}

Now everything works as expected:

  • Only you control when and where a confirmation dialog is appropriate by calling $PSCmdlet.ShouldProcess()
  • Any automatic confirmation dialog is suppressed by setting $ConfirmPreference to $false. Since this is a local variable, it only applies to the inside of your function

You may argue that by setting $ConfirmPreference to $false, any command outside your condition will always execute. And that is correct. Once you take things into your hands, it is your responsibility to identify all risky commands and place them into your conditional code. You can have as many such conditions as you need.

Wrap-Up

A lot of stuff was covered this time, and the attribute [CmdletBinding()]has much more to offer (which we look at in the next part of this series). Let me quickly summarize the most important points:

  • As a regular user, use $ConfirmPreference to control how often you want to see automatic confirmation dialogs, or set it to None to run code unattended.
  • When you create PowerShell functions, make sure you create Advanced Functions by using at least one attribute, even if it is empty, i.e. [CmdletBinding()].
  • When your PowerShell function makes changes to the system or otherwise has the potential to mess things up, add support for PowerShells risk mitigation system:
    • Add a ConfirmImpact level to your code that describes the risk level (Low, Medium, High)
    • Add support for -WhatIf and -Confirm by adding the value *SupportsShouldProcess
    • Execute risky code based on conditions. $PSCmdlet.ShouldProcess() tells you whether these conditions should execute.
    • Reset $ConfirmPreference to None inside your function to make sure you turn off all other confirmation popups and control execution entirely via your conditions.

What’s Next

This part took a deep look at the Risk Mitigation System which is one of the areas covered by the attribute [CmdletBinding()]. In the next part, we’ll be looking at the remaining tricks you can do with this attribute.

So please stay tuned if you are hungry for more! And make sure you have PowerShell Conference EU 2020 on your radar. That’s the place to be in June if you enjoy stuff like this. See you there!