Transformation attributes transform an object into something different. Apply them to any variable (or parameter), and whenever you assign new values to it, the attribute transforms the value magically before it is actually assigned.
Transformation attributes work similar to Validation Attributes with a slight difference: while validation attributes just check whether a value meets certain requirements, transformation attributes can actually change (“transform”) the object.
Often, such a transformation does not necessarily require transformation attributes. If all you want is transform a value from one data type to another, plain type conversion is enough:
# explicitly transform (cast) double to integer:
# returns 10:
[Int]9.7
# implicitly transform (cast) string to double:
[double]$result = 0
$result = "10.2"
# returns 10.2:
$result
# returns System.Double:
$result.GetType().FullName
# try a conversion (trycast):
# returns 11:
10.6 -as [Int]
# returns $null because conversion fails:
"test" -as [Int]
# use conversion as test tool:
function Test-Date($value) { $value -as [DateTime] -ne $null }
# returns $true:
Test-Date -Value "2020-03-01"
# returns $false:
Test-Date -Value "2020-14-14"
When a value cannot be converted to the desired target type, you cannot do much about it, though: either you receive an exception (cast) or $null
(trycast with the operator -as):
# casts throw exceptions when conversion fails:
[Int]"test"
# trycasts return $null when conversion fails:
"test" -as [Int]
The operator -as returns
$null
instead of throwing an exception when a conversion fails, but this operator has another unique feature: where applicable, it uses the current locale for conversions which is especially important with date conversions:# uses culture-neutral conversion (US): [DateTime]'2020-02-03' # uses current culture for conversion (result depends on your system): '2020-02-03' -as [DateTime]
That’s when transformation attributes come into play. A typical example is the conversion from string to credential:
# plain text cannot be converted to a credential and fails:
[PSCredential]$cred = 'Tobias'
With transformation attributes, this conversion is trivial:
# plain text can be converted to a credential by using the
# appropriate transformation attribute.
# make sure you use the correct order:
# works:
[PSCredential][System.Management.Automation.Credential()]$cred = 'Tobias'
# fails:
[System.Management.Automation.Credential()][PSCredential]$cred = 'Tobias'
Be careful with the order in which you apply the attribute. From the perspective of the variable
$cred
(right-to-left), first apply the transformation attribute to perform the transform, then perform the type cast.
Finding Built-In Transformation Attributes
Attributes are implemented by a type of same name, or optionally of same name plus the word “Attribute”. The attribute [System.Management.Automation.Credential()] is implemented by the type [System.Management.Automation.CredentialAttribute]:
[System.Management.Automation.CredentialAttribute]
IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True False CredentialAttribute System.Management.Automation.ArgumentTransformationAttribute
The property BaseType reveals that it is derived from the type [System.Management.Automation.ArgumentTransformationAttribute]. To find all transformation attributes shipping with PowerShell, search for all types that are derived from this type:
filter Test-Attribute([Type]$DerivedFrom)
{
# get the parent type of this type
$baseType = $_.BaseType
do
{
# if the parent is derived from the desired type,
# return it
if ($baseType -eq $DerivedFrom)
{
$_
}
# else walk up the inheritance chain
# by looking at the parent of the
# current parent until no more
# parent exists
$baseType = $baseType.BaseType
} while ($baseType)
}
# dump all PowerShell types by taking (any) powershell type,
# identify its assembly, and dump all public types
[PSObject].Assembly.GetTypes() |
Where-Object IsPublic |
# take only types that derive from "ArgumentTransformationAttribute"
Test-Attribute -DerivedFrom ([System.Management.Automation.ArgumentTransformationAttribute]) |
# remove "Attribute" suffix
ForEach-Object { $_.FullName -replace 'Attribute$' } |
# output in attribute syntax
ForEach-Object { "[$_()]"} |
Sort-Object
The output looks like this:
[Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformation()]
[System.Management.Automation.Credential()]
Apparently, there are two built-in transformation attributes:
- ArgumentToConfigurationDataTransformation: used by DSC (Desired State Configuration) to load configuration data from .psd1 files
- Credential: transforms a user name (string) into a credential object
Reading .psd1 Files to Hashtable
Even though the awkward ArgumentToConfigurationDataTransformation attribute was never designed for this, you can use it to super easily read .psd1 files and “transform”them to hashtables - at least in Windows PowerShell. There is probably no easier way to load configuration data into your scripts.
To play, we need a .psd1 file. Any file will do, so I am picking the manifest file for a module that is pretty probably present (the so-called PPP phenomenon):
$Path = Get-Module -Name PowerShellGet -ListAvailable | Select-Object -First 1 -ExpandProperty Path
notepad $Path
As you see here, .psd1 files are simply plain-text files with a hashtable inside of them. The PowerShelly way of an INI file.
Now lets read in the file content and convert the hashtable to an object (try this in Windows PowerShell first):
# reading a hashtable super easy:
# (make sure YOU KEEP THE ORDER of type and attribute!)
[hashtable][Microsoft.PowerShell.DesiredStateConfiguration.ArgumentToConfigurationDataTransformation()]$content = $Path
# access hashtable keys:
$content.Description
$content.ModuleVersion
Now that was easy, wasn’t it? Unfortunately, it isn’t as easy in PowerShell 7 where you get a Metadata Error.
The DSC team added this attribute for exactly this purpose: to easily read in .psd1-based configuration data.
On Windows PowerShell the attribute focuses on just that: reading any hashtable file. In PowerShell 7 the DSC team apparently added more checks to validate hashtable keys. So now it’s theirs again, and you can use the attribute only to read DSC-compliant hashtables. Shoot, it would have been a nice general purpose addition. But: if you are creating pure ad hoc Windows PowerShell solutions you are probably safe to use it.
If my assumptions are wrong, and you get this to work on PowerShell 7, be sure to leave a comment at the bottom of this page.
Some Background On Credential Attribute
You have seen [System.Management.Automation.Credential()] in the introduction already, and there isn’t much more to say: it converts a plain text user name into a string. Let’s rather focus on why the PowerShell team chose to add this attribute in PowerShell 2:
Whenever a function requires a credential, it adds a appropriate parameter, for example:
function Get-BiosInformation
{
param
(
[string]
$ComputerName,
[PSCredential]
$Credential
)
# take all submitted parameters, and forward them to
# this command to create a cim session:
$session = New-CimSession @PSBoundParameters
# query the information:
Get-CimInstance -ClassName Win32_BIOS -CimSession $session
# remove the session:
Remove-CimSession -CimSession $session
}
# use locally:
Get-BiosInformation
# use remotely with current identity:
Get-BiosInformation -ComputerName server12
# use remotely with credential:
Get-BiosInformation -ComputerName server12 -Credential superadmin
This example shows nicely the insane power of PowerShell functions and their parameter system: the function
Get-BiosInformation
encapsulates a WMI call so you don’t need to remember the complex WMI class names and can immediately retrieve the information locally, remotely, and remotely with alternate credentials. Visit the WMI Reference to find plenty more inspirations for functions similar to this one.It is beyond the scope of this article to explain the example in detail. Essentially, it uses splatting and
$PSBoundParameters
. This automatic variable contains a hashtable with the user-supplied parameters, and splatting is the programmatic way of submitting parameters to a cmdlet or function.
Without transformation attributes, users would have to supply an actual instance of a credential which is why in very old code you may have seen code similar to this:
# supply a credential object directly:
Get-BiosInformation -ComputerName server12 -Credential (Get-Credential superadmin)
Thanks to transformation attributes starting with PowerShell 2, users can today submit plain text, too, which is so much easier to work with:
# supply a plain text and let transformation attributes do the work:
Get-BiosInformation -ComputerName server12 -Credential superadmin
Except: the function Get-BiosInformation
wasn’t using a transformation attribute. So why did it transform anyway? The truth is: starting in PowerShell 3, the transformation attribute was silently integrated into the parameter system, and whenever a parameter uses the type [PSCredential], the attribute is added automatically.
For variables of type [PSCredential], you still need to manually apply the attribute like illustrated above. The magic works for parameters only.
If you ran the code above and used Get-BiosInformation
, you can check for yourself and list all attributes applied to the parameter -Credential:
(Get-Command -Name Get-BiosInformation).Parameters['Credential'].Attributes.Foreach{$_.GetType().FullName}
The transformation attribute is part of it:
System.Management.Automation.ParameterAttribute
System.Management.Automation.CredentialAttribute
System.Management.Automation.ArgumentTypeConverterAttribute
Variables: How Type Constraints Really Works
When you assign a type to a variable in PowerShell, the variable is now strongly typed and accepts only values of the given type. Anything else is “transformed” into the desired type, and if that won’t work, an exception is raised. You probably guessed already: this, too, is accomplished by transformation attributes:
# type a variable:
[int]$test = 12
# get the variable:
$variable = Get-Variable -Name test
# there is a new attribute: ArgumentTypeConverterAttribute
$variable.Attributes
As soon as you assign a type to a variable, the variable receives a [System.Management.Automation.ArgumentTypeConverter()] attribute:
TransformNullOptionalParameters TypeId
------------------------------- ------
True System.Management.Automation.ArgumentTypeConverterAttribute
Checking Type Constraint
Since the ArgumentTypeConverter attribute converts values to the desired target value, it must need to know the desired target type. Wouldn’t you want to know, too?
Unfortunately, this class is private, so you need to use reflection to get to the information, but it is entirely possible to retrieve the type assigned to any variable:
# type a variable:
[double]$test = 12
# get the variable:
$variable = Get-Variable -Name test
# get the ArgumentTypeConverterAttribute:
$attribute = $variable.Attributes |
Where-Object { $_.TypeId.Name -eq 'ArgumentTypeConverterAttribute' } |
Select-Object -First 1
# read the type:
# find value
$field = $attribute.GetType().GetFields('NonPublic,Instance') | Where-Object Name -eq '_convertTypes' | Select-Object -First 1
$field.GetValue($attribute)
When you encapsulate this into a function, you can investigate variable type constraints easily now:
# code to read type from PowerShell variables:
$code = {
# get the ArgumentTypeConverterAttribute:
$attribute = $this.Attributes |
Where-Object { $_.TypeId.Name -eq 'ArgumentTypeConverterAttribute' } |
Select-Object -First 1
if ($attribute)
{
# read the type:
$field = $attribute.GetType().GetFields('NonPublic,Instance') |
Where-Object Name -eq '_convertTypes' |
Select-Object -First 1
$field.GetValue($attribute)
}
}
# add new property "Type" to PowerShell variables
Update-TypeData -TypeName System.Management.Automation.PSVariable -MemberType ScriptProperty -MemberName Type -Value $code -Force
# investigate all variables
Get-Variable |
Select-Object -Property Name, Type, Value, Description |
Out-GridView
# output only typed variables:
Get-Variable | Where-Object Type | Select-Object -Property Name, Type, Value, Description
The result looks similar to this and obviously varies depending on the variables you defined in memory. The new property Type reveals the type of the variable:
Name Type Value Description
---- ---- ----- -----------
ConfirmPreference System.Management.Automation.ConfirmImpact High Dictates when confirmation should be reque...
DebugPreference System.Management.Automation.ActionPreference SilentlyContinue Dictates the action taken when a Debug mes...
ErrorActionPreference System.Management.Automation.ActionPreference Continue Dictates the action taken when an error me...
g System.String C:\windows\notepad.exe
InformationPreference System.Management.Automation.ActionPreference SilentlyContinue Dictates the action taken when a command g...
OutputEncoding System.Text.Encoding System.Text.SBCSCodePageEncoding The text encoding used when piping text to...
Path System.String C:\Windows\system32\WindowsPowerShell\v1.0\PowerShell_ISE.exe
ProgressPreference System.Management.Automation.ActionPreference Continue Dictates the action taken when progress re...
PSDefaultParameterValues System.Management.Automation.DefaultParameterDictionary {} Variable to hold all default <cmdlet:param...
test System.Double 12
types System.Type[] {System.Int32}
VerbosePreference System.Management.Automation.ActionPreference SilentlyContinue Dictates the action taken when a Verbose m...
WarningPreference System.Management.Automation.ActionPreference Continue Dictates the action taken when a Warning m...
Clearing Type
Since type constraints in variables are just a matter of applying an attribute, you can also remove attributes: simply strip the attribute from the variable to turn it back into a generic variable that accepts any type:
[int]$test = 123
# variable accepts numbers only, so this fails:
$test = "Hello"
# remove the ArgumentTypeConverter attribute:
$variable = Get-Variable -Name test
$attribute = $variable.Attributes |
Where-Object { $_.TypeId.Name -eq 'ArgumentTypeConverterAttribute' } |
Select-Object -First 1
$null = $variable.Attributes.Remove($attribute)
# variable now happily accepts any type:
$test = "Hello"
$test
Custom Transformation Attributes
PowerShell 5 introduced native support for classes, so now you can create your own transformation attributes with pure PowerShell code. Simply derive a class from System.Management.Automation.ArgumentTransformationAttribute and implement a method named Transform() that takes care of the desired transformation.
Handling SecureStrings
By default, plain text cannot be converted to secure string:
# this fails:
[SecureString]$secret = "Test"
Let’s change that:
# create a transform attribute that transforms plain text to secure string
class SecureStringTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
[object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
{
# if a securestring was submitted...
if ($inputData -is [SecureString])
{
# return as-is:
return $inputData
}
# if the argument is a string...
elseif ($inputData -is [string])
{
# convert to secure string:
return $inputData | ConvertTo-SecureString -AsPlainText -Force
}
# anything else throws an exception:
throw [System.InvalidOperationException]::new('Unexpected error.')
}
}
# create a variable that accepts both strings and securestrings
[SecureString][SecureStringTransform()]$secureText = "Hello"
$secureText
# accepts secure strings...
$secureText = Read-Host -AsSecureString -Prompt Password
$secureText
# accepts string...
$secureText = 'secret'
$secureText
The method Transform() receives the value to be transformed in $inputData
. It is completely up to you what logic you want to add to transform the input data into the desired type.
The example checks whether the input data is already a secure string, and if so, returns it as-is. Else, ConvertTo-SecureString
is used to convert the string input into a secure string.
A custom transform attribute is really simple but extremely powerful and versatile. Whenever you can’t transform objects via normal casts, you can now add your custom transform attributes.
Should you be scratching your head by now and wondering where the real use cases are, please have a look:
Masked Input Box for Sensitive Data
When a mandatory parameter uses the type [SecureString], you get a masked input box for free. So often secure string parameters are used even though the information will later be processed as plain text, just to get the free masked input box.
Here is a cool example that checks passwords for compromises: Test-Password
takes any password, checks it against an online database of compromised passwords, and returns the number of breaches. The password is safe when 0 breaches are reported.
This function asks for a secure string just to get the safe input box, then internally converts the secure string back to plain text.
function Test-Password
{
param
(
[Parameter(Mandatory)]
[SecureString]
$Password
)
# convert securestring back to plain text
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
$PlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
# turn plain text to bytes:
$bytes = [Text.Encoding]::UTF8.GetBytes($PlainText)
# create a new memory stream to read bytes from memory
# instead of file:
$stream = [IO.MemoryStream]::new($bytes)
# create hash from plain text, feeding text from memory stream:
$hash = Get-FileHash -Algorithm 'SHA1' -InputStream $stream
# close stream:
$stream.Close()
$stream.Dispose()
# split hash in two parts: first 5 characters, and remainder:
$first5hashChars,$remainingHashChars = $hash.Hash -split '(?<=^.{5})'
# enable HTTPS Tls1.2 protocol:
[Net.ServicePointManager]::SecurityProtocol = 'Tls12'
# check password for compromises by sending only the first
# 5 characters of hash (keeping it safe)
$url = "https://api.pwnedpasswords.com/range/$first5hashChars"
$response = Invoke-RestMethod -Uri $url -UseBasicParsing
# turn text into individual lines:
$lines = $response -split '\r\n'
# reading line where remainder of hash matches:
$filteredLines = $lines -like "$remainingHashChars*"
if ($filteredLines)
{
# split line by colon, last part has number of breaches:
($filteredLines -split ':')[-1]
}
else
{
# no compromises:
0
}
}
When you run the command without an argument, PowerShell automatically pops up a masked dialog:
Test-Password
You can now safely enter your password without risking that someone looks over your shoulder. The down side is: you can no longer easily submit arguments on the command line:
PS> Test-Password sunshine
Test-Password : Cannot process argument transformation on parameter 'Password'. Cannot convert the "sunshine" value of type "System.String" to type "System.Security.SecureString".
At line:1 char:15
+ Test-Password sunshine
+ ~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Test-Password], ParameterBindingArgumentTransformationException
+ FullyQualifiedErrorId : ParameterArgumentTransformationError,Test-Password
By applying the new [SecureStringTransform()] attribute to the parameter -Password, you suddenly get the best of both worlds: a free masked input box, and the ability to submit plain text on the command line:
function Test-Password
{
param
(
[Parameter(Mandatory)]
[SecureString]
# add the new transform here:
[SecureStringTransform()]
$Password
)
# convert securestring back to plain text
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
$PlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
# turn plain text to bytes:
$bytes = [Text.Encoding]::UTF8.GetBytes($PlainText)
# create a new memory stream to read bytes from memory
# instead of file:
$stream = [IO.MemoryStream]::new($bytes)
# create hash from plain text, feeding text from memory stream:
$hash = Get-FileHash -Algorithm 'SHA1' -InputStream $stream
# close stream:
$stream.Close()
$stream.Dispose()
# split hash in two parts: first 5 characters, and remainder:
$first5hashChars,$remainingHashChars = $hash.Hash -split '(?<=^.{5})'
# enable HTTPS Tls1.2 protocol:
[Net.ServicePointManager]::SecurityProtocol = 'Tls12'
# check password for compromises by sending only the first
# 5 characters of hash (keeping it safe)
$url = "https://api.pwnedpasswords.com/range/$first5hashChars"
$response = Invoke-RestMethod -Uri $url -UseBasicParsing
# turn text into individual lines:
$lines = $response -split '\r\n'
# reading line where remainder of hash matches:
$filteredLines = $lines -like "$remainingHashChars*"
if ($filteredLines)
{
# split line by colon, last part has number of breaches:
($filteredLines -split ':')[-1]
}
else
{
# no compromises:
0
}
}
Feeding Plain Text Into Cmdlets
Another use case is piping data into cmdlets that really require secure strings. Let’s assume you have a lot of data. This data can come from a database, a csv file, an excel report, you name it. For simplicity, I am simulating the data source like this:
@'
Name, Password
Tobias,Secret12
Willi,TopSecret123
Mary,DunnoTellMe
'@ | ConvertFrom-Csv
The result is a bunch of data objects:
Name Password
---- --------
Tobias Secret12
Willi TopSecret123
Mary DunnoTellMe
Let’s assume you want to pipe this into New-AdUser
or New-LocalUser
to create a bunch of new user accounts.
Get-AdUser
is part of the module ActiveDirectory which in turn is part of the free RSAT tools. You might need to download and install them first. Creating new user accounts requires an Active Directory and proper permissions. Since that’s a lot of prerequisites, I’ll instead useNew-LocalUser
to create local user accounts.
@'
Name, Password
Tobias,Secret12
Willi,TopSecret123
Mary,DunnoTellMe
'@ | ConvertFrom-Csv | New-LocalUser -WhatIf
This fails, though:
New-LocalUser : The input object cannot be bound because it did not contain the information required to bind all mandatory parameters: Password
At line:8 char:24
+ '@ | ConvertFrom-Csv | New-LocalUser -WhatIf
+ ~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidArgument: (@{Name=Tobias; Password=Secret12}:PSObject) [New-LocalUser], ParameterBindingException
+ FullyQualifiedErrorId : InputObjectMissingMandatory,Microsoft.PowerShell.Commands.NewLocalUserCommand
When you look at the parameters, the reason becomes evident:
PS> Get-Help -Name New-LocalUser -Parameter Password
-Password <SecureString>
Specifies a password for the user account. You can use `Read-Host -GetCredential`, Get-Credential, or ConvertTo-SecureString to create a SecureString object for the password.
If you omit the Password and NoPassword parameters, New-LocalUser prompts you for the new user's password.
Required? true
Position? named
Default value None
Accept pipeline input? True (ByPropertyName)
Accept wildcard characters? false
Both the parameters -Name and -Password accept pipeline input by property name, so that’s ok. However, the parameter -Password expects a secure string, and as you know by now, plain texts cannot be converted to secure strings by default.
With the new attribute [SecureStringTransform()], this conversion would be possible. So if you were to create your own version of New-LocalUser
, you could decorate the parameter -Password with this attribute and solve the issue. Only, you are not the owner of this cmdlet.
And this is where this story ends because there is no way to add attributes to commands that already exist. As a responsible and caring function author, you know by now though how you can create functions that are more flexible.
There is a neat trick to the problem above that I’d like to share. With it you can make above example (and any similar code) work. It is unrelated to transform attributes and rather related to parameter attributes I covered earlier:
@'
Name, Password
Tobias,Secret12
Willi,TopSecret123
Mary,DunnoTellMe
'@ | ConvertFrom-Csv | New-LocalUser -Password { $_.Password | ConvertTo-SecureString -AsPlainText -Force } -WhatIf
Here is the little-known secret: any parameter that is decorated with ValueFromPipelineByPropertyName automatically accepts a scriptblock that you can use to make adjustments to the piped data.
So in this case, the parameter -Password is receiving input from both ends: it gets a scriptblock, plus it receives data via the pipeline. The scriptblock executes four times, and $_
represents one data set at a time. I am reading the plain text password from property Password and converting it to a secure string.
Thanks to this lesser-known feature, you can easily fine-tune data that is piped from upstream cmdlets.
If you’d like to play with this feature a bit more, here is a test case:
function Test-Pipe
{
param
(
[string]
[Parameter(Mandatory,ValueFromPipelineByPropertyName)]
$Name
)
process
{
"receiving $Name"
}
}
# raw input values found in property "Name":
dir c:\windows | Test-Pipe
# adjusted input values
dir c:\windows | Test-Pipe -Name { $_.Name.ToUpper() }
dir c:\windows | Test-Pipe -Name { $_.FullName }
Handling Secrets
PowerShell comes with a built-in transformation attribute for credentials, so why not take that idea a step further, and add a credential manager to it. That’s why I created the new transformation attribute [PasswordManager()] and you’ll get the source code in a moment. Let’s first look at how the attribute works, and what it can do:
# store a credential in a variable AND REMEMBER IT:
[PSCredential][PasswordManager()]$credential = 'Tobi'
When you run this code (after you ran the new [PasswordManager()] attribute code below, of course), a credential dialog opens and asks for a password, then this credential is assigned to $credential
. The new attribute works exactly like the built-in [System.Management.Automation.Credential()] attribute so far.
But it can do a lot more because it has a secret vault. When you run the code again, you won’t be asked for a password any longer. The password manager has remembered your password. Even if you reboot your machine, your old credential is still there. No need to ever enter it again - except if you want to: when you prefix a username with “!”, the cache is overriden, and you can easily update passwords:
# update a cached password by prefixing username with "!"
$credential = '!Tobi'
Once you change your user name, you see the same thing: on first call you need to enter your password, on subsequent calls it is cached.
Pretty useful stuff, especially when you are testing code or have to enter credentials a lot. Needless to say that the password manager works for parameters as well: simply add the attribute to your function parameters to enable the safe password cache.
Curious to look at the code? It isn’t even that much:
class PasswordManagerAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
[object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
{
# this is where your encrypted passwords will be stored
# change this pass if you want to store elsewhere, i.e. on a usb disk
# the passwords are encrypted with your username and computer identity
# they can only be used on the same machine, and only by you:
[string]$StorePath = "$env:userprofile\secretstore.xml"
# if the file already exists...
[bool]$exists = Test-Path -Path $StorePath
if ($exists)
{
# load it (including all previously stored credentials)
[System.Collections.Hashtable]$store = Import-Clixml -Path $StorePath
}
else
{
# else, start with an empty hashtable:
$store = @{}
}
# if the argument is a string...
if ($inputData -is [string])
{
# does username start with "!"?
[bool]$promptAlways = $inputData.StartsWith("!")
# if not,...
if (!$promptAlways)
{
# ...check to see if the username has been used before,
# and re-use its credential (no need to enter password again)
if ($store.ContainsKey($inputData))
{
return $store[$inputData]
}
}
else
{
# ...else, remove the "!" at the beginning and prompt
# again for the password (this way, passwords can be updated)
$inputData = $inputData.SubString(1)
}
# ask for a credential:
$cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
# add the credential to the hashtable:
$store[$cred.UserName] = $cred
# update the hashtable and write it to file:
$store | Export-Clixml -Path $StorePath
# return the credential:
return $cred
}
# if a credential was submitted...
elseif ($inputData -is [PSCredential])
{
# save it to the hashtable:
$store[$inputData.UserName] = $inputData
# update the hashtable and write it to file:
$store | Export-Clixml -Path $StorePath
# return the credential:
return $inputData
}
throw [System.InvalidOperationException]::new('Unexpected error.')
}
}
Here is how it works:
- The password manager uses a file secretstore.xml in your user profile to persist the credentials. You can change the file location by changing
$storePath
. When you look inside this file you’ll see that your passwords are safely encrypted using your identity and your machine identity. This is using the same strong encryption that is used when Windows stores your passwords elsewhere. - To delete all cached credentials, simply delete the xml file.
- When a username is assigned to a variable decorated with this attribute, the method Transform() is called. It checks whether the xml file exists. If so, it reads its content via
Import-CliXml
, else it initializes a new empty array.Import-CliXml
andExport-CliXml
handle decryption and encryption automatically for any secure string. - If the input data is a string, Transform() looks up the username in the xml file, and if it finds the name, silently reads the cached credential. If the username is new, it opens the credential dialog and asks for the password. If the input data is a credential, this credential is used. Take a close look at
$engineIntrinsics
: this variable is a default argument to Transform() and can be used to access the current PowerShell host, i.e. to open dialogs like the credential dialog. - At the end, any updates (new usernames or changed credentials) are updated to the xml file and exported via
Export-CliXml
.
There are three ways for you to update cached passwords:
- Delete the xml file to remove all cached information and start over again.
- Submit a credential (i.e. call Get-Credential, then submit this to the variable (or parameter))
- Prefix the username with “!”. Transform() looks for exclamation marks, and if the submitted user name starts with it, it will always ask for a fresh credential.
Custom Attributes To Go
By now you have seen a number of useful custom attributes. You can of course copy and paste the source code into your scripts to use the custom attributes.
A much better and reusable way is to store the source code in a PowerShell module. However, you would then need to import the module by using the statement using module ...
.
With PowerShell classes, it is not sufficient to just import modules, or use the automatic module import. You must use the statement
using module...
. This sucks a bit, but you get used to it.If you don’t like this, you have too choices: wait a bit for Steve Lee to fix this limitation. Or translate the custom attributes to C#, and use
Add-Type
instead. Natively compiled C# code can be imported just fine from modules.
Creating Modules With Custom Attributes…
So as a final topic, I have extracted all the custom attributes from this article (and the previous one) and use this to create a reusable PowerShell module:
# 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.
}
}
# 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
})
}
}
# create a transform attribute that transforms plain text to secure string
class SecureStringTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
[object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
{
# if a securestring was submitted...
if ($inputData -is [SecureString])
{
# return as-is:
return $inputData
}
# if the argument is a string...
elseif ($inputData -is [string])
{
# convert to secure string:
return $inputData | ConvertTo-SecureString -AsPlainText -Force
}
# anything else throws an exception:
throw [System.InvalidOperationException]::new('Unexpected error.')
}
}
class PasswordManagerAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
[object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
{
# this is where your encrypted passwords will be stored
# change this pass if you want to store elsewhere, i.e. on a usb disk
# the passwords are encrypted with your username and computer identity
# they can only be used on the same machine, and only by you:
[string]$StorePath = "$env:userprofile\secretstore.xml"
# if the file already exists...
[bool]$exists = Test-Path -Path $StorePath
if ($exists)
{
# load it (including all previously stored credentials)
[System.Collections.Hashtable]$store = Import-Clixml -Path $StorePath
}
else
{
# else, start with an empty hashtable:
$store = @{}
}
# if the argument is a string...
if ($inputData -is [string])
{
# does username start with "!"?
[bool]$promptAlways = $inputData.StartsWith("!")
# if not,...
if (!$promptAlways)
{
# ...check to see if the username has been used before,
# and re-use its credential (no need to enter password again)
if ($store.ContainsKey($inputData))
{
return $store[$inputData]
}
}
else
{
# ...else, remove the "!" at the beginning and prompt
# again for the password (this way, passwords can be updated)
$inputData = $inputData.SubString(1)
}
# ask for a credential:
$cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
# add the credential to the hashtable:
$store[$cred.UserName] = $cred
# update the hashtable and write it to file:
$store | Export-Clixml -Path $StorePath
# return the credential:
return $cred
}
# if a credential was submitted...
elseif ($inputData -is [PSCredential])
{
# save it to the hashtable:
$store[$inputData.UserName] = $inputData
# update the hashtable and write it to file:
$store | Export-Clixml -Path $StorePath
# return the credential:
return $inputData
}
throw [System.InvalidOperationException]::new('Unexpected error.')
}
}
Next, run this line to create a new folder named customattributes in your personal module folder, and open the new folder in Windows Explorer:
explorer (New-Item -Path (Join-Path ($env:PSModulePath -split ';' -like "$env:userprofile*") -ChildPath 'customattributes') -ItemType Directory).FullName
Save the script from above into this folder, and name it customattributes.psm1. WARNING: the file must use the same name as the folder, and the file extension must be .psm1.
Now you can import the custom attributes with this line:
using module customattributes
If you’d like to save the file in a different location, you can specify a full file path. For example:
using module C:\Users\tobias\Documents\stuff\customattributes.psm1
The statement using module ...
must be the first statement in a script file, and it only accepts literals. You cannot use variables, and you cannot use relative paths. Referencing an absolute path in your scripts is not a good idea for most parts. That’s why creating a module (however simple) and storing it in one of the default module locations is the recommended way.
Here is a full example (assuming you saved the custom attributes to the customattributes module folder as instructed above):
# import custom validators from file:
using module customattributes
# test variable logger:
[LogVariable('logHere', SourceName='test')]$test = $null
[LogVariable('logHere', SourceName='credential')]$credential = $null
[LogVariable('logHere', SourceName='secureText')]$secureText = $null
# test path validator:
[ValidatePathExists()][string]$test = "c:\windows"
[ValidatePathExists()][string]$test = "c:\"
[ValidatePathExists()][string]$test = "c:\fails"
$test
# test password manager:
[PSCredential][PasswordManager()]$credential = 'Tobi'
# second call is cahed:
$credential = 'Tobi'
$credential
# test secure string transform:
[SecureString][SecureStringTransform()]$secureText = "Hello"
$secureText = 'Secret'
# retrive variable logger details
$logHere | Out-GridView
$secureText
What’s Next
This part took a deep look at the transformation attributes, extending this new playground even more with a deeper understanding of variable typing, and new custom transformation attributes. In the next part, I’ll look at un(der)documented system attributes, and how they can be useful in PowerShell!
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!