Secure code uses safe-guards: they ensure that values assigned to your variables (and parameters) are valid. They alarm you when they are not. One safe-guard is to use appropriate data types. Another lesser-known safe-guard are validation attributes which I’ll cover today.
Essentially, with validation attributes, you can attach live code to variables and have PowerShell execute this code whenever new values are assigned to your variables. This can be used for data validation, ensuring that only valid values are assigned.
It can be used for a whole lot of other things, too, though. At the end of this article, I’ll introduce a custom variable logger that can help with debugging scripts.
Enforcing Valid Values
One important safe-guard for your code makes sure that variables (and parameters) accept valid values only. The most popular safe-guard is choosing an appropriate data type:
# without safe-guard: variable accepts anything:
$id = 12
$id = "Hello"
# with safe-guard: variable accepts only values that can
# be converted into the desired data type:
[int]$id = 12
# this fails:
$id = "Hello"
# this works because a [double] can be converted to [int]:
$id = 9.8
# result is a rounded [int]:
$id
Once you assign a data type to a variable, from now on it accepts only values that can be converted to this type.
Since parameters in PowerShell are plain variables, all of what you read here applies to both variables and parameters.
Fine-Tune: Use Validation Attributes
PowerShell comes with a range of validation attributes that you can use to define more specific requirements for your variables (and parameters):
Attribute | Description |
---|---|
[ValidateCount()] | Minimum and maximum number of elements in an array |
[ValidateDrive()] | Allowable drive letters in a path |
[ValidateLength()] | Minimum and maximum length of a string |
[ValidateNotNull()] | May not be $null |
[ValidateNotNullOrEmpty()] | May neither be $null or an empty array or an empty string |
[ValidatePattern()] | Must match a Regular Expression pattern |
[ValidateRange()] | Must be a number in the given range |
[ValidateScript()] | Validation script must return $true |
[ValidateSet()] | Must be one of the listed strings |
[ValidateTrustedData()] | Introduced in PowerShell 6, used internally |
[ValidateUserDrive()] | Must be a path using the User drive. This drive can be defined in JEA (Just Enough Administration) session configurations. |
These validation attributes cover a range of widely used validity checks, so instead of having to write your own validation code, you can simply attach the appropriate attribute to a variable (or parameter):
# computername must be a string between 8 and 12 characters:
[ValidateLength(8,12)][string]$computername ='Server2018'
# this works (string is between 8 and 12 char):
$computername = 'Server001'
# this fails (string is too short):
$computername = 'pc1'
Typically, you end up with a combination of data type and validation attribute to best describe what you expect to be assigned to a variable (or parameter).
Validating String Content
The data type [string] is very unspecific: almost anything can be represented as string. When you add validation attributes, you can be much more specific.
Empty Values
To prevent empty string values, use [ValidateNotNullOrEmpty()]. Do not use [ValidateNotNull()] because an empty string is still a string and not $null
:
# this variable cannot store empty strings
[ValidateNotNullOrEmpty()][string]$Path = 'c:\somefolder'
# this fails:
$Path = ""
Typically, this attribute is used to ensure that parameters receive a value:
function Test-Input
{
param
(
[ValidateNotNullOrEmpty()]
[string]
$UserName
)
"You entered $UserName"
}
# this works:
Test-Input -UserName Tobias
# this fails:
$username = 'Tobias'
Test-Input -UserName $usernam # <- note the typo in the variable name
The validation takes place only when values are assigned to a variable, so you do get away with empty values when the user does not submit anything:
PS> Test-Input
You entered
To make sure you always receive a value, either assign a default value, or make the parameter mandatory:
function Test-Input
{
param
(
[Parameter(Mandatory)]
[string]
$UserName
)
"You entered $UserName"
}
# this works:
Test-Input -UserName Tobias
# this fails:
Test-Input -UserName ""
When a parameter is mandatory, there is no need for [ValidateNotNullOrEmpty()] because mandatory parameters receive this attribute by default.
Sometimes it may be necessary to do the opposite: allow empty values for mandatory parameters. Add the attribute [AllowNullOrEmpty()].
String Length
If you know a string must be of given size, use [ValidateLength()] and specify the minimum and maximum number of characters:
# computername must be a string between 8 and 12 characters:
[ValidateLength(8,12)][string]$computername ='Server2018'
# this works (string is between 8 and 12 char):
$computername = 'Server001'
# this fails (string is too short):
$computername = 'pc1'
Unfortunately, attribute values must be literal. You cannot use variables inside of attributes.
Set of Strings
If there are only a number of fixed choices, use [ValidateSet()] to define the legal string values:
# define the legal string values
[ValidateSet('NewYork','London','Berlin')][string]$City = 'Berlin'
# this works:
$city = 'Berlin'
# this fails:
$city = 'Hannover'
Support for IntelliSense and Tab Completion
This attribute is especially useful with parameters because IntelliSense and tab completion pick up the legal values:
function Test-ValidateSet
{
param
(
[ValidateSet('NewYork','London','Berlin')]
[string]
$City = 'Undefined'
)
"Choice: $City"
}
# this works:
Test-ValidateSet -City Berlin
# this works:
Test-ValidateSet
# this fails:
Test-ValidateSet -City Hannover
IMPORTANT: when used in parameters, the variable can initially receive any value (in the example above: “Undefined”). When used with variables, the initial assignment must use one of the legal values.
Case-Sensitivity
The attribute is case-insensitive by default. If you require case-sensitivity, use the value IgnoreCase:
# define the legal string values (case-sensitive)
[ValidateSet('NewYork','London','Berlin', IgnoreCase=$false)][string]$City = 'Berlin'
# this works:
$city = 'Berlin'
# this fails because casing is not correct:
$city = 'berlin'
Regular Expression
For more specific validity checks, use a Regular Expression pattern:
# allow any text that starts with "Server", followed by 2 to 4 digits:
[ValidatePattern('^Server\d{2,4}$')][string]$ComputerName = 'Server12'
# works:
$ComputerName = 'Server9999'
# fails:
$ComputerName = 'Server2'
$ComputerName = 'Server12345'
It is beyond the scope of this article to cover Regular Expressions. There are plenty of great resources on how to define string patterns using Regular Expressions.
In short: Regular Expressions always match one character at a time and consist of literal characters, wildcards, quantifiers, and anchors:
- ^: Anchor, beginning of text
- Server: literal text
- \d: Wildcard, any number (digit)
- $: Anchor, end of text
Paths
If a string is a path, and you want to limit the path to a set of drives, use [ValidateDrive()]:
# allow any path that starts with the defined drives:
[ValidateDrive('c','d','env')][string]$Path = 'c:\windows'
# works:
$Path = 'env:username'
# fails:
$Path = 'e:\test'
Validating Arrays
To accept arrays with a certain number of elements, use [ValidateCount()]:
function Test-Input
{
param
(
[Parameter(Mandatory)]
# allow a maximum of 3 strings:
[ValidateCount(1,3)]
[string[]]
$ComputerName
)
# return arguments:
$PSBoundParameters
}
# works:
Test-Input -ComputerName server12
# works:
Test-Input -ComputerName server12, dc1, dc2
# fails (too few):
Test-Input -ComputerName @()
# fails (too many):
Test-Input -ComputerName server12, dc1, dc2, dc3
Validating Numbers
You can use data types to roughly restrict the range of numbers, for example [Byte], [Int16], [Int32] or [Int64]. To define a custom range, use [ValidateRange()].
The below function Out-Speech
uses the Text-to-Speech engine to convert text to speech. The speed must be in the range of -10 (slow) to 10 (fast). Since there is no data type that covers this exact range, and since the user needs to be protected from entering invalid values, the attribute does all the validation for you.
The text is also validated by a different attribute ensuring it is between 1 and 200 characters long:
function Out-Speech
{
param
(
# require a text no longer than 200 characters
# and no shorter than 1 characters
[Parameter(Mandatory)]
[ValidateLength(1,200)]
[string]
$Text,
# require a number between -10 and 10
[ValidateRange(-10,10)]
[int]
$Speed = 0
)
$sapi = New-Object -ComObject Sapi.SpVoice
$sapi.Rate = $Speed
$null = $sapi.Speak($Text)
}
# works:
Out-Speech -Text 'Hello World!' -Speed 3
Out-Speech -Text 'I am afraid I am drunk.' -Speed -10
# fails (speed to small):
Out-Speech -Text 'Hello World!' -Speed -20
General Purpose Script Validators
If your validation requirement isn’t covered by any of the specific validation attributes, there is a generic [ValidateScript()] attribute that can validate just about anything. Simply submit a scriptblock. When the scriptblock returns $true
, then the value passes the test.
Here is a validator that checks for existing files:
# allow any path that starts with the defined drives:
[ValidateScript({ Test-Path -Path $_ -PathType Leaf } )][string]$Path = 'c:\windows\explorer.exe'
# works:
$Path = (Get-Process -Id $pid).Path
# fails (does not exist):
$Path = 'e:\doesnotexist.txt'
# fails (is no file):
$Path = 'c:\windows'
Creating Custom Validation Attributes
Beginning with PowerShell 5, you can create your own classes, so you can also create your own validation attributes now because internally attributes are classes.
This way, you can develop your own reusable validators and don’t have to pollute your code with extensive [ValidateScript()] scriptblocks anymore.
Here is an example for a simple custom [ValidatePathExists()] attribute:
# create your own class derived from
# System.Management.Automation.ValidateArgumentsAttribute
# by convention, your class name should be suffixed with "Attribute"
# the type name is "ValidatePathExistsAttribute", and the derived attribute
# name will be "ValidatePathExists"
class ValidatePathExistsAttribute : System.Management.Automation.ValidateArgumentsAttribute
{
# this class must override the method "Validate()"
# this method MUST USE the signature below. DO NOT change data types
# $path represents the value assigned by the user:
[void]Validate([object]$path, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics)
{
# perform whatever checks you require.
# check whether the path is empty:
if([string]::IsNullOrWhiteSpace($path))
{
# whenever something is wrong, throw an exception:
Throw [System.ArgumentNullException]::new()
}
# check whether the path exists:
if(-not (Test-Path -Path $path))
{
# whenever something is wrong, throw an exception:
Throw [System.IO.FileNotFoundException]::new()
}
# if at this point no exception has been thrown, the value is ok
# and can be assigned.
}
}
# clean and short new attribute
[ValidatePathExists()][string]$Path = "c:\windows"
# works:
$Path = (Get-Process -Id $pid).Path
# fails (does not exist):
$Path = 'e:\doesnotexist.txt'
Explaining the Code
Whenever PowerShell wants to assign a new value to a variable, it checks whether the variable has an attached attribute of type [System.Management.Automation.ValidateArgumentsAttribute] or is derived from this type.
To create a new custom validator attribute, simply derive a new class from this type. PowerShell expects this class to have a method named Validate(). So when a variable is about to receive a new assignment, PowerShell calls Validate() and submits the value plus its own engine intrinsics.
Inside your own Validate() method, you can perform any checks you like. If the check fails, simply throw an exception. If no exception is thrown, the new value is considered to be accepted.
Gotchas
There are three gotchas here:
-
The name of your new class is the name of the attribute. However, it is good practice to suffix your class name with Attribute. If you do, the attribute name ignores the suffix.
-
The method Validate() must implement the arguments shown in the example. Do not try and change the data type from [Object] to anything else.
-
If you place your code inside a PowerShell module, the validation attribute will no longer work. This is a known limitation of PowerShell class support. Modules cannot automatically export PowerShell classes. You would have to manually load the module via the new statement
using
:# load a module with all of its PowerShell-based classes using module XYZ # replace XYZ with actual module name
C# to the Rescue
Custom validation attributes are great, and the example above is just the tip of the iceberg. However, it may not be the smartest idea to implement custom attributes using PowerShell, at least when you plan to ship your code via PowerShell modules.
If you know how to program in C#, you can easily create custom validation attributes that are much more robust:
- work with any PowerShell version
- can be automatically imported from PowerShell modules
Example: Custom Validation Attribute in C#
To get you going, I have translated the [ValidatePathExists()] attribute from above to C#. I renamed it to [ValidateFileOrFolderExists()] so there is no ambiguity should you run both examples in one PowerShell environment:
$code = @'
using System;
using System.Collections.Generic;
using System.Management.Automation;
public class ValidateFileOrFolderExistsAttribute : System.Management.Automation.ValidateArgumentsAttribute
{
protected override void Validate(object path, EngineIntrinsics engineEntrinsics)
{
if (string.IsNullOrWhiteSpace(path.ToString()))
{
throw new ArgumentNullException();
}
if(!(System.IO.File.Exists(path.ToString()) || System.IO.Directory.Exists(path.ToString())))
{
throw new System.IO.FileNotFoundException();
}
}
}
'@
# compile c# code
Add-Type -TypeDefinition $code
# clean and short new attribute
[ValidateFileOrFolderExists()][string]$Path = "c:\windows"
# works:
$Path = (Get-Process -Id $pid).Path
# fails (does not exist):
$Path = 'e:\doesnotexist.txt'
Inspiration: Variable Logger
Validation attributes are so much more powerful than you might think: essentially, you can attach custom code that executes whenever variable values are assigned.
To make you thinking I created a little variable logger based on a validation attribute for you:
# create a new custom validation attribute named "LogVariableAttribute":
class LogVariableAttribute : System.Management.Automation.ValidateArgumentsAttribute
{
# define two properties
# they turn into optional attribute values later:
[string]$VariableName
[string]$SourceName = 'Undefined'
# this is the class constructor. It defines all mandatory attribute values:
LogVariableAttribute([string]$VariableName)
{
$this.VariableName = $VariableName
}
# this gets called whenever a new value is assigned to the variable:
[void]Validate([object]$value, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics)
{
# get the global variable that logs all changes:
[System.Management.Automation.PSVariable]$variable = Get-Variable $this.VariableName -Scope global -ErrorAction Ignore
# if the variable exists and does not contain an ArrayList, delete it:
if ($variable -ne $null -and $variable.Value -isnot [System.Collections.ArrayList]) { $variable = $null }
# if the variable does not exist, set up an empty new ArrayList:
if ($variable -eq $null) { $variable = Set-Variable -Name $this.VariableName -Value ([System.Collections.ArrayList]@()) -Scope global -PassThru }
# log the variable change to the ArrayList:
$null = $variable.Value.Add([PSCustomObject]@{
# use the optional source name that can be defined by the attribute:
Source = $this.SourceName
Value = $value
Timestamp = Get-Date
# use the callstack to find out where the assignment took place:
Line = (Get-PSCallStack).ScriptLineNumber | Select-Object -Last 1
Path = (Get-PSCallStack).ScriptName | Select-Object -Last 1
})
}
}
When you run this code, you get a new attribute [LogVariable()]. Attach it to any variable you want to log. The attribute takes a mandatory value (the name of the global variable that stores the log results) and an optional value named SourceName so you can keep track of multiple logged variables.
Here is a practical use case:
# attach the logger to the variable and specify the
# name of the global variable ('myLoggerVar') that should
# log variable changes, plus optionally a source identifier
# that gets added to the log:
[LogVariable('myLoggerVar', SourceName='Init')]$test = 1
[LogVariable('myLoggerVar', SourceName='Iterator')]$x = 0
# start using the variables:
for ($x = 1000; $x -lt 3000; $x += 300)
{
"Frequency $x Hz"
[Console]::Beep($x, 500)
}
$test = "Hello"
Start-Sleep -Seconds 1
$test = 1,2,3
# looking at the log results:
$myLoggerVar | Out-GridView
Save this code and run it. The logged results look similar to this:
Source Value Timestamp Line Path
------ ----- --------- ---- ----
Init 1 03.03.2020 15:17:44 43 C:\User...gger.ps1
Iterator 0 03.03.2020 15:17:44 44 C:\User...gger.ps1
Iterator 1000 03.03.2020 15:17:44 47 C:\User...gger.ps1
Iterator 1300 03.03.2020 15:17:45 47 C:\User...gger.ps1
Iterator 1600 03.03.2020 15:17:45 47 C:\User...gger.ps1
Iterator 1900 03.03.2020 15:17:46 47 C:\User...gger.ps1
Iterator 2200 03.03.2020 15:17:46 47 C:\User...gger.ps1
Iterator 2500 03.03.2020 15:17:47 47 C:\User...gger.ps1
Iterator 2800 03.03.2020 15:17:47 47 C:\User...gger.ps1
Iterator 3100 03.03.2020 15:17:48 47 C:\User...gger.ps1
Init Hello 03.03.2020 15:17:48 53 C:\User...gger.ps1
Init {1, 2, 3} 03.03.2020 15:17:49 55 C:\User...gger.ps1
Awesome, eh?
What’s Next
This part took a deep look at the validation attributes and ended with a great new playground: custom validation attributes. In the next part, I’ll look at transformation attributes - they are even more powerful!
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!