Creating Custom Attributes

Why not create your own PowerShell attributes? It only takes a couple of lines, and the results can be astonishing!

The awesome thing about PowerShell is that you can be both user and author: it comes with all capabilities to build anything that you typically consume. That’s why PowerShell is an enourmously useful didactic tool, too. Today I’ll look at creating attributes that I consumed in previous parts.

Let’s start with a quick overview that focuses on the basics of creating and using custom attributes.

Creating New Custom Attributes

A PowerShell attribute is a class that derives from the type [System.Attribute], so it takes only a few lines of code to add new custom attributes. Just remember that the name of your class should end with Attribute (although that is no requirement).

Note also that PowerShell classes were added in PowerShell 5. If you are using an older version, make sure you update it (which you should for a multitude of reasons anyway) or if you can’t do that, install PowerShell 7 side-by-side.

#requires -Version 5.0

# custom attributes derive from the type "Attribute":
class PesterTestAttribute : Attribute 
{
    # field to store constructor argument:
    [string]$TestName = ''

    # field becomes an optional named argument later:
    [int]$Level = 0
    
    # field becomes an optional named argument later:
    [bool]$IsParam = $false
    

    # constructor with one argument, 
    # this argument becomes a mandatory positional argument later:
    PesterTestAttribute([string]$TestName)
    {
        $this.TestName = $TestName
    }
    
    # constructor with NO argument,
    # turns the positional mandatory argument into a positional optional argument later:
    PesterTestAttribute() {}
}

When you run this, you end up with a new attribute [PesterTest()]. The suffix Attribute in our class name PesterTestAttribute is always ignored. Before we look at what you can do with it, let’s first focus on its anatomy:

  • Mandatory Positional Arguments: Attributes can have mandatory arguments. They are always positional. They are implemented as class constructor arguments. Our example class uses one positional argument that is internally stored in $TestName. Here is a use case: [PesterTest(‘Subject’)].
  • Optional Positional Arguments: If you want a positional argument to be optional instead of mandatory, add another parameterless constructor, or in the case of multiple parameters, one that omits the parameter that you want to keep optional. That’s why the example code adds a second parameterless constructor. Here is a use case: [PesterTest()].
  • Optional Named Arguments: Attributes can also have named arguments in the form of key=value. These are always optional and implemented as properties. The example code uses two optional arguments called Level, implemented as property $Level, and IsParam, implemented as property $IsParam. Here are use cases: [PesterTest(Level=12)],[PesterTest(IsParam)], [PesterTest(Level=4,IsParam)], [PesterTest(‘LogToFile’,Level=6)].

You can combine arguments using comma separated lists, and you can apply attributes multiple times to the same structure, as you’ll see in a second.

Applying Custom Attributes

You can’t use attributes on its own (because it wouldn’t make any sense), but you can attach them to all kinds of things, for example to variables, parameters, classes or functions.

That’s not completely true: actually, an attribute defines what the targets are that it can be attached to. If you don’t define targets, PowerShell allows you to attach the attribute to anything (that potentially accepts attributes).

No Arguments

This is how you would attach the attribute to a variable. In this example, the attribute does not use any arguments:

[PesterTest()]$a = 1

Positional Arguments

You can attach attributes to scriptblocks as well. Attributes must come first in scriptblocks, and scriptblocks must use a param() statement.

Attributes were introduced in PowerShell 2 as part of Advanced Functions, so they work only in Advanced Functions, not in Simple Functions. The requirements for an Advanced Function are:

  1. uses at least one attribute (which we do anyway since we are applying a custom attribute) and
  2. uses a param() structure, even if it is empty.

If you are omitting a param() structure, PowerShell creates a Simple Function and surfaces arguments in automatic variables such as $args and $input. Attributes are illegal in such scriptblocks and cause syntax errors.

Note that this requirement is true for scriptblocks in general, so you can use attributes in plain scriptblocks as well as long as you also add a param() statement.

In this example, the attribute uses a positional parameter. Our positional parameter was of type [string] so any text will do:

function Test-This
{
  [PesterTest('Parameterblock')]
  param
  (
    [string]
    $Name
  )

}

Named Arguments

You can attach the attribute to parameters as well (which are just variables). In this example, the attribute uses a positional and two named arguments.

The named arguments are defined by the property name of our attribute class:

  • The property Level is defined as [int], so it must be a number.

  • The property IsParam is defined as [bool] so it must be $true or $false.

    Beginning in PowerShell 3, named attribute parameters of type [bool] do not need to have a value. If you omit the value, the attribute is set to $true. So in the example below, IsParam and IsParam=$true have the identical effect.

function Test-This
{
  param
  (
    [PesterTest('Variable', Level=6, IsParam)]
    [string]
    $Name
  )


}

Reading Custom Attributes

Attaching custom attribute to a number of things is simple, yet it won’t do anything. You simply attached information (“metadata”) to code. Attributes start to become useful when someone actually is reading the metadata.

Those who define attributes must typically also take care of reading the attributes. PowerShell defined a number of attributes such as [Parameter()], [Hidden()], or [ValidateSet()], and these attributes are ready-to-use because PowerShell actively recognizes them and reads their content.

Your custom attributes are gracefully ignored by PowerShell. In order for them to do something exciting, you must actively search for them and read their content.

I called the custom attribute [PesterTest()] to tickle your inspiration: it could be used with Pester unit testing to attach metadata to your code. This metadata could be used to skip certain tests, request log levels, or even ask to send emails to someone in case a test failed. That however won’t happen for free. You would have to identify and read the [PesterTest()] attributes yourself within your Pester test code and respond accordingly.

Fortunately, it is also possible to create custom attributes that derive from predefined PowerShell attributes. I’ll be looking at this later. These attributes are automatically recognized and read by PowerShell, so they immediately start to do something.

To check out how custom attributes can be identified and read, run this sample function first where I attached our new custom object to a number of code structures:

function Test-Attribute
{
  [PesterTest('ReturnValue')]
  [PesterTest('Log',Level=1)]
  param
  (
    [PesterTest('Name', IsParam)]
    [string]
    $Name
  )

  [PesterTest()]$init = 100
}

Reading ScriptBlock Attributes

Each scriptblock has a property Attribute that surfaces all attached attributes:

$scriptblock = {
  [PesterTest('ReturnValue')]
  [PesterTest('Log')]
  param()
  'Hello!'
}

$scriptblock.Attributes

All attached attributes are returned:

TestName    Level IsParam TypeId             
--------    ----- ------- ------             
ReturnValue     0   False PesterTestAttribute
Log             1   False PesterTestAttribute

WARNING: You are not the only one that can attach attributes to scriptblocks, so you should make sure you are reading only the attributes you are looking for:

# read a specific attribute type:
$scriptblock.Attributes.Where{$_.TypeId.Name -eq 'PesterTestAttribute' }

Reading Function Attributes

A PowerShell function is a scriptblock with a name, so use Get-Command to get to the scriptblock:

# read attributes attached to function "Test-Attribute"
# define this function first (see above)
(Get-Command -Name Test-Attribute).ScriptBlock.Attributes
TestName    Level IsParam TypeId             
--------    ----- ------- ------             
ReturnValue     0   False PesterTestAttribute
Log             1   False PesterTestAttribute

Reading Parameter Attributes

Use Get-Command to get a CommandInfo object. It exposes all parameters in its property Parameters which is a hashtable. Parameter use attributes all the time so it is crucial to filter the attributes and select the one(s) you want to read:

(Get-Command -Name Test-Attribute).Parameters["Name"].Attributes.Where{$_.TypeId.Name -eq 'PesterTestAttribute' }
TestName Level IsParam TypeId             
-------- ----- ------- ------             
Name         0    True PesterTestAttribute

If you attach attributes to parameters in a anonymous scriptblock that has no CommandInfo structure, you can read attributes only via the PowerShell Ast:

$scriptblock = {
  [PesterTest('ReturnValue')]
  [PesterTest('Log',Level=3,IsParam)]
  param()
  'Hello!'
}

$scriptBlock.Ast.ParamBlock.Attributes | Select-Object -Property TypeName, PositionalArguments, NamedArguments

The result is a static analysis performed by the PowerShell parser:

TypeName   PositionalArguments NamedArguments  
--------   ------------------- --------------  
PesterTest {'ReturnValue'}     {}              
PesterTest {'Log'}         {Level, IsParam}

Reading Variable Attributes

To access attributes attached to variables, use Get-Variable:

# apply attribute to variable:
[PesterTest(Level=5)]$test=12

# read attribute:
(Get-Variable -Name test).Attributes
TestName Level IsParam TypeId             
-------- ----- ------- ------             
             5   False PesterTestAttribute

Why Using Custom Attributes?

As you have seen, you can attach extra information to PowerShell elements such as classes, functions, scriptblocks, parameters, and variables. This is useful only when you later read these attributes and add some action. Here are a couple of typical use-cases:

  • Unit Testing: When you use Pester to write tests for your code, you can use attributes like [PesterTest()] to add testing metadata to your code. Either use the metadata in your attribute to decide whether certain tests should be performed or not, or adjust logging detail. You could even add email addresses that need to be notified in case of test failure.
  • Auto-Documentation: When you use PowerShell to auto-document your code, you can use attributes to tag variables and functions that you want to include into the documentation.

Tagging Variables

To illustrate that it is no rocket science to use custom attributes for code tagging, here is a simple example that defines an attribute to tag variables, then uses the attributes to auto-document variables.

# define very simple attribute:
class DocumentAttribute : Attribute {}

# use attribute to tag important variables that you want to 
# include into your documentation:
[Document()]$important = 12

$notimportant = "I am hidden"

[Document()]$addThis = "I am not!"

Listing Variables (Run-Time)

After you ran the code, you can use Get-Variable to list only important (tagged) variables:

# dump only tagged variables:
Get-Variable | Where-Object { $_.Attributes.Where{$_.TypeId.Name -eq 'DocumentAttribute'}}
Name                           Value
----                           -----
addThis                        I am not!
important                      12

Listing Variables (Design-Time)

If you just want to auto-document the PowerShell code without having to actually run it, use the Ast that was covered previously:

# path to powershell script (adjust!):
$Path = "C:\file.ps1"

# parse code and find all ast objects...
[System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null).FindAll({ 
    # ... that are a variable expression...
    $args[0] -is [System.Management.Automation.Language.VariableExpressionAst] -and
    # ...and are part of an attributed expression...
    $args[0].Parent -is [System.Management.Automation.Language.AttributedExpressionAst] -and
    # ...and use the attribute "Document"
    $args[0].Parent.Attribute.TypeName.Name -eq 'Document'
    }, $true) |
    # output variable name, type, position
    Select-Object -Property VariablePath, StaticType -ExpandProperty Extent |
    Select-Object -Property VariablePath, StaticType, StartLineNumber, StartColumnNumber, StartOffset, File |
    Select-Object -Property * -ExcludeProperty VariablePath -ExpandProperty VariablePath |
    Select-Object -Property UserPath, StaticType, Start*, Is*, File |
    Sort-Object -Property UserPath
UserPath           : addThis
StaticType         : System.Object
StartLineNumber    : 8
StartColumnNumber  : 13
StartOffset        : 120
IsGlobal           : False
IsLocal            : False
IsPrivate          : False
IsScript           : False
IsUnqualified      : True
IsUnscopedVariable : True
IsVariable         : True
IsDriveQualified   : False
File               : C:\file.ps1

UserPath           : important
StaticType         : System.Object
StartLineNumber    : 4
StartColumnNumber  : 13
StartOffset        : 56
IsGlobal           : False
IsLocal            : False
IsPrivate          : False
IsScript           : False
IsUnqualified      : True
IsUnscopedVariable : True
IsVariable         : True
IsDriveQualified   : False
File               : C:\file.ps1

Deriving Custom Attributes

A very powerful approach is to derive your custom attributes from attributes that are already recognized by PowerShell so your attribute is read and the code inside your attribute is invoked automatically by PowerShell in certain scenarios.

  • [ArgumentCompleterAttribute]: Create attributes that invoke code whenever PowerShell performs an argument completion, and create your own powerful custom argument completers
  • [ValidateArgumentsAttribute]: Create attributes that invoke code whenever values are assigned to variables or parameters.
  • [ArgumentTransformationAttribute]: Create attributes that invoke code whenever values are assigned to variables or parameters, and take control over and adjust these values.

Custom Argument Completers

I already covered PowerShell argument completion and how you can use the attribute [ArgumentCompleter()] to add powerful dynamic argument completion to function parameters.

The attribute [ArgumentCompleter()] is a very generic attribute though, and it expects that you submit your entire code logic as an argument to the attribute. Since attributes generally only take literal arguments (and no variables), there is no way for you to re-use this code, and the attribute becomes huge and hard to read.

Building Better Argument Completers

The PowerShell team has already built a way into [ArgumentCompleter()] that does allow code reuse. With Get-AttributeInfo covered here you can uncover this approach:

$attributes = Get-AttributeInfo
$attributes.ArgumentCompleter

The attribute takes either a scriptblock with PowerShell code, or a type that embeds the argument completion code and makes it re-usable:

Name        Kind       Type                                    
----        ----       ----                                    
type        Positional System.Type                             
scriptBlock Positional System.Management.Automation.ScriptBlock

Compare ScriptBlock…

You can always use PowerShell code to create the argument completion values, however this creates a lot of code that needs to be literally inserted as argument into every instance of the attribute. Let’s take a look at this approach first to better understand the alternatives.

Test-NounCompleterManual implements the parameter -Noun that auto-completes available command nouns. It is controlled by another parameter -Module: when the user specifies a valid module name, parameter -Noun only suggests the available nouns from the commands implemented by this module:

function Test-NounCompleterManual
{
  param
  (
    [ArgumentCompleter({
          # receive information about current state:
          param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    
          # start collecting parameters for Get-Command
          $parameters = @{}
          
          # test whether there is a parameter -Module
          if ($fakeBoundParameters.ContainsKey('Module'))
          {
            $parameters["Module"] = $fakeBoundParameters["Module"]  
          }
    
          # list all commands
          Get-Command -CommandType Function,Cmdlet @parameters | 
          # take commands with Noun
          Where-Object Noun |
          Select-Object -ExpandProperty Noun |
          # eliminate duplicates
          Sort-Object -Unique |
          # filter results by word to complete
          Where-Object { $_.LogName -like "$wordToComplete*" } | 
          Foreach-Object { 
            [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
          }
            })]
        $Noun,
        
        
        [String]
        $Module
    )
}

I am not going over this code in detail. It is covered in a separate article.

Take a look at how Test-NounCompleterManual provides argument completion:

# completes the nouns found in the module specified:
Test-NounCompleterManual -Module Microsoft.PowerShell.Utility -Noun  # ISE opens automatically Intellisense menu, press CTRL+SPACE in VSCode

# completes the nouns found in ALL module specified:
Test-NounCompleterManual -Noun  # ISE opens automatically Intellisense menu, press CTRL+SPACE in VSCode

…To ICompleter Types:

The previous example worked great but the attribute was stuffed with a lot of code that was not re-usable. If you planned to use the attribute elsewhere again, you’d have to copy and paste the entire attribute including all logic to create the argument completion choices.

A more efficient way replaces the scriptblock by a re-usable type that implements the interface IArgumentCompleter. There are some of these types predefined in PowerShell, and I am using [Microsoft.PowerShell.Commands.NounArgumentCompleter] in the next example. Note how much shorter and easier to read this code is:

function Test-NounCompleter
{
    param
    (
      [ArgumentCompleter([Microsoft.PowerShell.Commands.NounArgumentCompleter])]
      $Noun,
        
        
      [String]
      $Module
    )
}

Once you run the code, Test-NounCompleter provides the same argument completion. This time, though, the completion logic is encapsulated in the type I submitted to the attribute:

# completes the nouns found in the module specified:
Test-NounCompleter -Module Microsoft.PowerShell.Utility -Noun  # press CTRL+SPACE in VSCode

# completes the nouns found in ALL module specified:
Test-NounCompleter -Noun  # press CTRL+SPACE in VSCode

Except: there is a small difference. ISE no longer opens the IntelliSense menu automatically. You now must press Ctrl+ Space to open it. That’s the default in VSCode anyway.

Take a look at the IntelliSense menu to spot the cause: the built-in completer uses the default Text icon whereas our custom code above uses the more appropriate icon ParameterValue. This icon does not only look better, ISE opens IntelliSense for argument completion automatically only when the completion item uses the icon ParameterValue.

What’s Better? ScriptBlock vs. IArgumentCompleter

Types that implement IArgumentCompleter can be great provided you find one that completes the values you want, and the completer is programmed well. In the case of NounArgumentCompleter, the choice of completion icon is incorrect, and there is no way for you to fix this. And the number of built-in argument completer types is limited:

[PSObject].Assembly.GetTypes() | Where-Object { $_.ImplementedInterfaces.Name -contains 'IArgumentCompleter' } | Select-Object -ExpandProperty FullName
Microsoft.PowerShell.Commands.ExperimentalFeatureNameCompleter
Microsoft.PowerShell.Commands.NounArgumentCompleter
Microsoft.PowerShell.Commands.PSEditionArgumentCompleter (PowerShell 7)
System.Management.Automation.PropertyNameCompleter       (PowerShell 7)

ScriptBlocks on the contrary are completely under your code control, and you can adjust the code anytime if want it to behave differently. But you cannot re-use the code and must submit it each time you use the [ArgumentCompleter()] attribute. This is ugly, but what’s worse, it makes maintaining the code next to impossible.

To create simplified code completion attributes that can re-use code, you have two choices:

  • Create your own classes that implement IArgumentCompleter. This is entirely possible but not the focus of this article. This approach simplifies the attribute somewhat but won’t allow the user to submit any arguments to your argument completion code.
  • Create your own attribute and derive it from [ArgumentCompleter()]. This is the most flexible approach and results in very small and easy to use attributes while encapsulating your custom code.

Custom Argument Completer Attribute

Let’s assume you want to auto-complete customer names, so you are using this code:

function Get-CustomerInfo
{
  param
  (
    # suggest customer names
    [ArgumentCompleter({
          # receive information about current state:
          param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
          # list all customers...
          'Microsoft', 'Amazon', 'Google' | 
          Sort-Object -Property LogName |
          # filter results by word to complete
          Where-Object { $_.LogName -like "$wordToComplete*" } | 
          Foreach-Object { 
            [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
          }
    })]
    [string]
    $Customer
  )


  "Hello $Customer!"
}

IMPORTANT: Due to a long-standing bug in all versions of PowerShell including version 7, argument completion will not work in the editor pane that defines the function you are using. It only works for the interactive console and any other open script pane.

That’s annoying while developing code but not really an issue for end users: typically, production code is loaded from PowerShell modules or scripts, so the function that is using argument completion attributes is typically not located in the script pane that you are working in.

When you run it, and then use Get-CustomerInfo -Customer , it works beautifully, and you get a suggestion for your customer names (but are not restricted to the suggestions like when using an enum or [ValidateSet()]).

However, the attribute is huge because of its complex scriptblock argument.

Derive From [ArgumentCompleterAttribute]

Now let’s create a custom attribute that derives from [ArgumentCompleterAttribute], the type that implements the attribute [ArgumentCompleter()]:

class CustomerAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
  
  # constructor calls base constructor and submits the completion code:
  
  CustomerAttribute() : base({
      # receive information about current state:
      param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
      # list all customers...
      'Microsoft', 'Amazon', 'Google' | 
      Sort-Object -Property LogName |
      # filter results by word to complete
      Where-Object { $_.LogName -like "$wordToComplete*" } | 
      Foreach-Object { 
        [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
      }
    }) 
    {
      # constructor has no own code
    }
}

Whenever you need argument completion for customers, use your new custom attribute [Customer()]:

function Get-CustomerInfo
{
  param
  (
    # suggest customer names:
    [Customer()]
    [string]
    $Customer
  )


  "Hello $Customer!"
}

Thanks to your new custom attribute [Customer()], the argument completion code is encapsulated now, re-usable and not getting in the way.

It is also well maintainable now: to improve it, simply change the code in your attribute definition. Changes become effective wherever you use the attribute in your code.

Explaining The Code

The custom attribute is a class deriving from [ArgumentCompleterAttribute]:

class CustomerAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
}

The name of your new attribute is the name of your class less the suffix Attribute.

The class uses a constructor that calls the base constructor, which is the constructor of [ArgumentCompleterAttribute]:

CustomerAttribute() : base({ <# completion code #> })
{
	# empty
}

The class submits the PowerShell scriptblock with the argument completion logic to the base constructor. After all, [ArgumentCompleter()] expected this scriptblock, so that’s exactly what you pass to the base constructor. The remainder of the constructor is empty because we leave the rest of the work to the default logic implemented in [ArgumentCompleterAttribute].

Adding Custom Arguments

Your custom attribute [Customer()] is great, and when you have new customers you can adjust the attribute. In fact, simple attributes like this are very easy to use and produce simple production code so you could create a number of these highly specific attributes for all the use cases you come across.

Then again, at the beginning of this article you learned that custom attributes can take arguments. You could just as well create custom argument completer attributes that are more versatile:

class CompleteAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
  
  # constructor calls base constructor and submits the completion code:
  # added a mandatory positional argument $Values with the autocompletion values
  # this argument is passed to a static method that creates the scriptblock that the base constructor wants:
  
  CompleteAttribute([string[]] $Items) : base([CompleteAttribute]::_createScriptBlock($Items)) 
  {
    # constructor has no own code
  }
    
  # create a static helper method that creates the scriptblock that the base constructor needs
  # this is necessary to be able to access the argument(s) submitted to the constructor
  hidden static [ScriptBlock] _createScriptBlock([string[]] $Items)
  {
    $scriptblock = {
      # receive information about current state:
      param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
      # list all submitted values...
      $Items | 
      Sort-Object -Property LogName |
      # filter results by word to complete
      Where-Object { $_.LogName -like "$wordToComplete*" } | 
      Foreach-Object { 
        [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
      }
    }.GetNewClosure()
    return $scriptblock
  }
}

The new attribute [Complete()] now can be used for any type of static argument completion:

function Get-CustomerInfo
{
  param
  (
    # suggest customer names:
    [Complete(('Karl','Jenny','Zumsel'))]
    [string]
    $Customer
  )


  "Hello $Customer!"
}

Simply submit the completion items as an argument to the attribute [Complete()].

Unfortunately, you have to place the list of completion strings in parens. If you do use parens, PowerShell interprets each string as a new argument and won’t create one string array.

You can work around this by defining the attribute parameter as a so-called parameter array, however this is not possible with PowerShell-defined classes. You would have to define your custom attribute using C# which is explained here.

Explaining The Code

Since the attribute [Complete()] should be controllable by the user, it needs arguments. I added one argument of type [string[]] to the class constructor ($Items):

CompleteAttribute([string[]] $Items) : base([CompleteAttribute]::_createScriptBlock($Items)) 
  {
    # constructor has no own code
  }

The challenge is: how can submitted arguments be transferred to the base constructor? The base constructor expects a scriptblock that already contains the submitted argument.

That’s why I added a hidden static method _createScriptBlock which accepts one argument. This method is called to produce the base constructor argument, and the original argument is used as argument to this method.

_createScriptBlock simply defines the scriptblock that creates the argument completion choices. This scriptblock uses the variable $Items. However, the scriptblock isn’t executed immediately. Instead, it will be passed on to the base constructor, and the base constructor will later invoke the scriptblock whenever argument completion is required.

$scriptblock = {
      # receive information about current state:
      param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
      # list all submitted values...
      # $Items EXISTS IN PARENT SCOPE ONLY!
      $Items | 
      Sort-Object -Property LogName |
      # filter results by word to complete
      Where-Object { $_.LogName -like "$wordToComplete*" } | 
      Foreach-Object { 
        [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
      }
    }.GetNewClosure()  # <- call GetNewClosure() to embed parent variables like $Items before returning the scriptblock
    return $scriptblock

So the scriptblock will be called in a different scope and no longer has access to the variable $Items. That’s why GetNewClosure() creates a new closure of the scriptblock before returning it. A closure is a way to copy variables from parent scopes into the scriptblock so that it can safely use these variables even if they no longer exist in the parent scope.

Using Optional Named Arguments

Attributes can have optional named arguments as well, and maybe you’d like to add a named argument Icon to choose the icon for completion items this way:

[Complete(('a','b','c'), Icon='History')]

Before we look at how attributes implement named arguments, let’s first look at the available icon choices:

Supported Icons

These are the icons supported by PowerShell argument completion:

[Enum]::GetNames([System.Management.Automation.CompletionResultType]) | 
  Sort-Object
Command
DynamicKeyword
History
Keyword
Method
Namespace
ParameterName
ParameterValue
Property
ProviderContainer
ProviderItem
Text
Type
Variable

Implement Named Argument

Attributes implement optional named arguments by defining properties. That’s all. It’s really that simple.

What’s way more complex is: how do you pass these properties to the base constructor? The base constructor is called before the own constructor code executes. At this time, optional named arguments have not yet been bound to the object instance, or in other words: there is no way for you to access optional named arguments while the constructor runs.

However, there’s a really simple workaround: pass a reference to your object instance to the scriptblock that the base constructor gets. After all, this scriptblock isn’t invoked immediately. It is just put aside, waiting to be invoked later when argument completion is requested.

So if the scriptblock has its own internal reference to the object instance of your attribute, it can read its properties. By the time the scriptblock is invoked, these properties are filled with the values of the named arguments, and all is fine.

Sounds a bit weird, and it is, but it is working well:

# define the attribute:

class CompleteAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
  # add an optional parameter
  [string]$Icon = 'ParameterValue'
  
  # constructor calls base constructor and submits the completion code:
  # added a mandatory positional argument $Values with the autocompletion values
  # this argument is passed to a static method that creates the scriptblock that the base constructor wants
  # also pass reference to object instance ($this) to be able to access optional parameters like $Icon later:
  CompleteAttribute([string[]] $Items) : base([CompleteAttribute]::_createScriptBlock($Items, $this)) 
  {
    # constructor has no own code
  }
    
  # create a static helper method that creates the scriptblock that the base constructor needs
  # this is necessary to be able to access the argument(s) submitted to the constructor
  # the method needs a reference to the object instance to (later) access its optional parameters:
  hidden static [ScriptBlock] _createScriptBlock([string[]] $Items, [CompleteAttribute] $instance)
  {
    $scriptblock = {
      # receive information about current state:
      param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
      # list all submitted values...
      $Items | 
      Sort-Object -Property LogName |
      # filter results by word to complete
      Where-Object { $_.LogName -like "$wordToComplete*" } | 
      Foreach-Object { 
        [System.Management.Automation.CompletionResult]::new($_, $_, $instance.Icon, $_)
      }
    }.GetNewClosure()
    return $scriptblock
  }
}


# example:
function Get-CustomerInfoNew
{
  param
  (
    # suggest customer names:
    [Complete(('Karl','Jenny','Zumsel'),Icon='History')]
    [string]
    $Customer
  )


  "Hello $Customer!"
}

Playing With Icons

Run the code above, then try Get-CustomerInfoNew:

Get-CustomerInfoNew -Customer # press CTRL+SPACE

The argument completion menu opens, and you see your choices plus the History icon.

Now, change the named argument Icon in the attribute to a different icon name like Namespace for example (but make sure you use a legal name from the list above):

[Complete(('Karl','Jenny','Zumsel'),Icon='Namespace')]

Run the code again, and you’ll see the icon change. Pretty slick, eh?

Here are three things to consider when you test-drive the code:

  1. Argument completion attributes do not work in the script pane where the function is defined that uses the argument completion attribute. Test argument completion in the interactive console, or any other open script pane.
  2. ISE opens argument completion IntelliSense menus automatically when you use the icon ParameterValue. With any other icon, you need to manually open the menu via Ctrl+ Space. VSCode always requires this.
  3. You must use one of the predefined names for the icon. If you omit the named argument, the default icon ParameterValue is used. If you use an icon name that is illegal, your custom argument completion silently breaks, and you get default argument completion.

Custom Validation Attributes

Validation attributes are recognized by PowerShell whenever a value is assigned to a variable (or parameter). The original purpose of validation attributes is to validate that the assigned value adheres to certain rules.

How Validation Attributes Work

Here is a quick example illustrating what validation attributes do:

function Connect-Customer
{
    param
    (
        [string]
        # make sure the string value is one of the listed values:
        [ValidateSet('Microsoft', 'Amazon', 'Google')]
        $Customer
    )

    "Hello $Customer!"
}

The validation attribute [ValidateSet()] makes sure the string assignment to the parameter -Customer is one of the listed customer names. When the user tries to assign a different value, an exception is raised. PowerShell even uses the values defined by the attribute to provide argument completion:

Connect-Customer -Customer  # Intellisense pops up in ISE. Press CTRL+SPACE in VSCode

Since PowerShell uses the attribute values to provide awesome argument completion automatically, you may be asking yourself why you should go through the hassle of creating argument completion attributes. [ValidateSet()] is a built-in attribute that provides perfect argument completion absolutely for free.

Yet it works differently. Since the primary goal is to validate arguments, [ValidateSet()] restricts user input to exactly the values defined by the attribute. Argument completion is a nice feature you get on top.

Pure argument completion attributes like [Customer()] do not validate user input. So the user gets convenient argument completion but is not limited to the values suggested. It depends on your use case which approach is the better one.

Why Create Custom Validation Attributes?

There are plenty of built-in validation attributes that are ready-to-use. I covered them here. One great reason to add new custom validation attributes is to add more validation scenarios, i.e. validate that paths exist, and you find a complete walk-through here.

There are many more use cases for validation attributes. Just keep in mind: their embedded code gets invoked whenever PowerShell assigns values to variables (or parameters). So you can create custom validation attributes that really don’t validate anything. I show-cased a universal variable logger that logs whenever values are assigned to variables.

Self-Learning Argument Completion

Today, I want to focus on another highly creative and unusual use case for custom validation attributes: a self-learning argument completion!

The Plan

Let’s assume you have written a PowerShell function with a parameter -Computername so users can connect to different servers. Server names can be complex, so argument completion would come handy.

However, there is really no way for you to suggest computer names, especially when you create functions for other people. You simply don’t know the computer names, they may change over time, and there may be thousands of them.

With a custom validation attribute that you apply to your function parameter, you can easily add code that automatically creates a personal self-learning hint list: whenever a user submits a computer name to the parameter, the attribute adds it to the hint list. An additional argument completion attribute then provides dynamic argument completion, based on the computer names collected in the hint list.

Creating Self-Learning Validation Attribute

Let’s first implement the self-learning validation attribute. I have commented the code below to explain it. For a more detailed discussion of custom validation attributes, please refer to my previous article.

class AutoLearnAttribute : System.Management.Automation.ValidateArgumentsAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'

    # define parameterless constructor:
    AutoLearnAttribute() : base()
    {}

    # define constructor with parameter for id:
    AutoLearnAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # Validate() is called whenever there is a variable or parameter assignment
    [void]Validate([object]$value, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.hint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # add to hashtable
              ForEach-Object {
                # value is not used, set it to $true:
                $hints[$_] = $true
              }
        }

        # add new value to hint list
        if(![string]::IsNullOrWhiteSpace($value))
        {
            $hints[$value] = $true
        }
        # save hints list
        $hints.Keys | Sort-Object | Set-Content -Path $hintPath -Encoding Default 
        
        # skip any validation (we only care about logging)
    }
}

Testing Self-Learning Attribute

The new custom validation attribute [AutoLearn()] can be applied to variables and parameters. It logs all values to a hint file. Let’s test this functionality:

# apply new attribute to a variable:
[AutoLearn()]$test = "Hello"

# variable is now self-learning and logs all assignments:
$test = 123
$test = "check this out"

# read logged values:
Get-Content -Path $env:temp\hints\default.hint

The result is a sorted list of all values assigned to $test so the attribute works as expected:

123
check this out
Hello

When you apply [AutoLearn()] to function parameters, let’s look at your options to customize the hint files:

Customizing Self-Learning Attribute

The attribute maintains multiple hint lists. After all, you may want to apply the attribute to different parameters and keep separate hint lists for all kinds of things, i.e. computer names, paths, user names, whatever.

By default, all assignments are written to the file default.hint. The attribute supports an optional positional argument that lets you choose the file name. To log computer names to a hint file named servers.hint, try this:

function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        # auto-learn computer names to servers.hint
        [AutoLearn('servers')]
        $ComputerName
    )

    "connecting you to $ComputerName"
}

Now go ahead and run Connect-MyServer a couple of times, and submit a number of computer names. It does not matter whether you submit the computer names to the parameter or get prompted for it.

Since the attribute uses a positional argument ‘servers’, the assignments are written to a different file this time:

# read logged values:
Get-Content -Path $env:temp\hints\servers.hint

Again, the result is a sorted list of all values you ever assigned to the parameter -ComputerName.

All hint files are stored in the subfolder hints inside the temp folder $env:temp by default. You can change this location. The attribute sports an optional named argument Path, so this example writes the hint files to C:\myhints instead of the default location:

function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        # auto-learn computer names to servers.hint
        # in folder c:\myhints:
        [AutoLearn('servers',Path='c:\myhints')]
        $ComputerName
    )

    "connecting you to $ComputerName"
}

After you run this code, then call Connect-MyServer a number of times, you can check the results:

# read logged values:
Get-Content -Path c:\myhints\servers.hint

IMPORTANT: attributes accept literal strings only, so you cannot submit variables to the attribute. This severely limits the option to define custom paths: only absolute literal paths are allowed.

Adding Argument Completion

By now we have [AutoLearn()] which logs parameter values, but there is no argument completion yet. For argument completion, you need a second attribute that derives from [ArgumentCompleter()].

Fortunately, we have a running example for this already and only need to adapt it: instead of submitting a list of fixed autocompletion suggestions, the attribute now takes the same arguments as [AutoLearn()] and reads the completion values from the hint files:

class AutoCompleteAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'
  
    # define parameterless constructor:
    AutoCompleteAttribute() : base([AutoCompleteAttribute]::_createScriptBlock($this)) 
    {}

    # define constructor with parameter for id:
    AutoCompleteAttribute([string]$Id) : base([AutoCompleteAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    # create a static helper method that creates the scriptblock that the base constructor needs
    # this is necessary to be able to access the argument(s) submitted to the constructor
    # the method needs a reference to the object instance to (later) access its optional parameters:
    hidden static [ScriptBlock] _createScriptBlock([AutoCompleteAttribute] $instance)
    {
    $scriptblock = {
        # receive information about current state:
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        # create filename for hint list
        $filename = '{0}.hint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # filter completion items based on existing text:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              # create argument completion results
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}

Final Example

Now you can easily add self-learning argument completion to your functions. All you need to do is apply the attributes [AutoLearn()] (for the self-learning part) and [AutoComplete()] (for the argument completion based on the self-learning lists) to those parameters that you want to equip with self-learning argument completion.

Make sure you use unique id names for your parameters so you maintain separate lists for separate parameters. Both attributes take the same arguments, so simply specify the same file name.

This effortless enhancement will most definitely add tremendous usability value to your functions and also an almost guaranteed “wow”-effect. All you need to do is add two simple attributes per parameter:

function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        # auto-learn user names to user.hint
        [AutoLearn('user')]
        # auto-complete user names from user.hint
        [AutoComplete('user')]
        $UserName,

        [string]
        [Parameter(Mandatory)]
        # auto-learn computer names to server.hint
        [AutoLearn('server')]
        # auto-complete computer names from server.hint
        [AutoComplete('server')]
        $ComputerName
    )

    "hello $Username, connecting you to $ComputerName"
}

When you run Connect-MyServerfor the first time you won’t get any fancy argument completion. However, both the values for -UserName and -ComputerName are already logged to separate hint lists. When you call Connect-MyServer again, argument completion starts to kick in, and over time, your completion lists become more and more detailed.

Before you play and see for yourself, here are a couple of things to keep in mind:

  • Due to a bug in PowerShell, argument completion will not work inside the script pane where the function is defined. Try it in the interactive console or a different script pane.
  • ISE opens argument completion intellisense menus automatically. In VSCode, as always, you need to press Ctrl+ Space to invoke argument completion.
  • In the PowerShell console, press Tab for tab completion.

There are a couple of things that still need fine-tuning. I’ll cover this in the next couple of days:

  • To remove cached values from your hint lists, you currently need to manually remove them from the hint files, or delete the hint files altogether to start over.

  • When you export your functions to a PowerShell module, all custom attributes break. That’s because PowerShell-defined classes are not imported from modules. I’ll show you some workarounds soon. Meanwhile, you can resort to this line to load your modules with all custom attributes:

    using module XYZ # replace XYZ with your module name
    

Custom Transformation Attributes

Transformation attributes work very similar to validation attributes: they too get invoked whenever you assign values to variables or parameters. Yet they are even more powerful because they cannot just check assignments but have the power to alter them.

How Transformation Attributes Work

PowerShell comes with only a few built-in transformation attributes, and only one of them is commonly used: [System.Management.Automation.Credential()] transforms a string (a user name) into a credential.

So while a direct cast from [string] to [PSCredential] fails…

# this fails:
[System.Management.Automation.Credential()][PSCredential]$cred = 'Tobias'

…this conversion can be performed by a transformation attribute:

# this works:
[PSCredential][System.Management.Automation.Credential()]$cred = 'Tobias'

Order matters: make sure you first apply the transformation attribute and only then apply the type (read assignments from right to left).

I have covered the built-in transformation attributes in great detail previously.

Why Custom Transformation Attributes?

Transformation attributes come handy whenever you want to convert a data type into another data type and there is no built-in cast. For example, I created a custom transformation attribute that converts plain text to secure strings.

This makes it super easy to use mandatory PowerShell function parameters that prompt with a masked input box but continues to support plain text input for automation.

As you have seen in the section about custom validation attributes above, attributes can be useful far beyond their original purpose: the custom validation attribute [AutoLearn()] did not care about validation at all and instead cached parameter input.

The very same could have been achieved with a custom transformation attribute because it, too, gets invoked when values are assigned to variables or parameters. And in fact this makes total sense. The attribute [AutoLearn()] still has one flaw: while you can add new values to your hint lists, you cannot remove values or clear hint lists.

A Better [AutoLearn()] Attribute

Let’s implement the custom attribute [AutoLearn()] again, and this time derive from a transformation attribute instead of a validation attribute. Then, add a way for the user to clear his cached hint lists.

The Plan

When the user enters a value that starts with an exclamation mark, clear the hint list and remove the exclamation mark from the user input before the value is assigned.

Obviously, once we start using a leading exclamation mark to control the clearing of hint lists, the user will no longer be able to enter values that start with exclamation marks since the transformation attribute strips the exclamation mark from the input.

So we’ll add another optional named argument to the attribute that controls the character that is used to clear the lists in case you want to use a different one.

Creating [AutoLearn()] With Built-In Clearing Feature

I took the code for our custom validation attribute and rewrote it as a transformation attribute. I commented the code so you can better understand what is going on there. For a more detailed discussion of transformation attributes, please refer to my previous article.

The below definition of [AutoLearn()] replaces the previous approach and adds the capability to clear hint lists:

# derive from ArgumentTransformationAttribute:
class AutoLearnAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'

    # define prefix character used to delete the hint list
    [char]$ClearKey = '!'

    # define parameterless constructor:
    AutoLearnAttribute() : base()
    {}

    # define constructor with parameter for id:
    AutoLearnAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # Transform() is called whenever there is a variable or parameter assignment, and returns the value
    # that is actually assigned:
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.hint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # add to hashtable
              ForEach-Object {
                # value is not used, set it to $true:
                $hints[$_] = $true
              }
        }

        # does the user input start with the clearing key?
        if ($inputData.StartsWith($this.ClearKey))
        {
            # remove the prefix:
            $inputData = $inputData.SubString(1)

            # clear the hint list:
            $hints.Clear()
        }

        # add new value to hint list
        if(![string]::IsNullOrWhiteSpace($inputData))
        {
            $hints[$inputData] = $true
        }
        # save hints list
        $hints.Keys | Sort-Object | Set-Content -Path $hintPath -Encoding Default 
        
        # return the user input (if there was a clearing key at its start,
        # it is now stripped):
        return $inputData
    }
}

Clearing AutoComplete Lists

Run the code above to overwrite [AutoLearn()] and replace it with the new implementation. Then run Connect-MyServer to test-drive the new clearing capabilities:

Please follow the examples in this article. I am assuming you ran the code for [AutoComplete()] and Connect-MyServer before and just replaced [AutoLearn()] with its new and enhanced version.

Connect-MyServer -Computername abc -Username !willi

Note how I prepended the username with an exclamation mark. Note also how the output has stripped off the exclamation mark:

hello willi, connecting you to abc

When you run Connect-MyServer again, you’ll notice that the argument completion for -UserName has been cleared and now only suggests willi.

Adjusting The Clearing Character

Let’s assume your function parameter expects user input that can start with exclamation marks. In this case, pick a different character to serve as clearing hint:

function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        # auto-learn user names to user.hint
        # and prefix "^" to clear lists
        [AutoLearn('user',ClearKey='^')]
        # auto-complete user names from user.hint
        [AutoComplete('user')]
        $UserName,

        [string]
        [Parameter(Mandatory)]
        # auto-learn computer names to server.hint
        [AutoLearn('server',ClearKey='^')]
        # and prefix "^" to clear lists
        # auto-complete computer names from server.hint
        [AutoComplete('server')]
        $ComputerName
    )

    "hello $Username, connecting you to $ComputerName"
}

Note how this function uses the named attribute argument ClearKey='^' to change the clearing key from the default exclamation mark to ”^”. Likewise, this line would reset the now the hint files with new values:

Connect-MyServer -ComputerName ^Server12 -UserName ^Testuser

Safe Secrets Store For Parameters

Often, it’s hard to handle secrets such as credentials in a safe way. Entering passwords and credentials manually, on the other hand, sucks. So why not take all the newly gained knowledge and create a highly effective and safe PowerShell credential store? Here is the plan:

The Plan

As you have seen above, PowerShell comes with a built-in transformation attribute that transforms string-based usernames to credentials. When this happens, a credential dialog pops up, and you need to manually enter the password.

Why not extend this attribute and add a credential cache that safely backs up all credentials you ever entered? And combine this with convenient argument completion, so whenever you come across a function that expects a credential, you can conveniently pick from the cached user names, or add a new one.

This would be awesome for production, but would also help tremendously with testing: instead of having to enter credentials all the time when running test code, you’d simply re-read previous credentials from the cache.

Is It Safe?

Of course this mechanism needs a solid and safe way to store cached credentials. I am not daring to implement this myself. Instead, I am simply using the credential encryption that is built into Windows.

Implementation

Below is the entire implementation. Most of the code is using the same strategies and principles I just explained. New parts are commented.

# attribute [CacheCredential()] transforms strings to creds and caches values:
class CacheCredentialAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
    [char]$ClearKey = '!'

    CacheCredentialAttribute() : base()
    {}

    CacheCredentialAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.xmlhint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # hint list is xml data
            # it is a serialized hashtable and can be 
            # deserialized via Import-CliXml if it exists
            # result is a hashtable:
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath
        }

        # if the argument is a string...
        if ($inputData -is [string])
        {
            # does username start with "!"?
            [bool]$promptAlways = $inputData.StartsWith($this.ClearKey)

            # 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 ($hints.ContainsKey($inputData))
                {
                    return $hints[$inputData]
                }
            }
            else
            {
                # ...else, remove the "!" at the beginning and prompt
                # again for the password (this way, passwords can be updated)
                $inputData = $inputData.SubString(1)
                # delete the cached credentials
                $hints.Clear()
            }
            # ask for a credential:
            $cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
            # add the credential to the hashtable:
            $hints[$cred.UserName] = $cred
            # update the hashtable and write it to file
            # passwords are automatically safely encrypted:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $cred
        }
        # if a credential was submitted...
        elseif ($inputData -is [PSCredential])
        {
            # save it to the hashtable:
            $hints[$inputData.UserName] = $inputData
            # update the hashtable and write it to file:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $inputData
        }
        throw [System.InvalidOperationException]::new('Unexpected error.')
    }
}


# attribute [SuggestCredential()] suggests cached credentials
class SuggestCredentialAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
  
    SuggestCredentialAttribute() : base([SuggestCredentialAttribute]::_createScriptBlock($this)) 
    {}

    SuggestCredentialAttribute([string]$Id) : base([SuggestCredentialAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    hidden static [ScriptBlock] _createScriptBlock([SuggestCredentialAttribute] $instance)
    {
    $scriptblock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        $filename = '{0}.xmlhint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # read serialized hint hashtable if it exists...
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath

            # hint the sorted list of cached user names...
            $hints.Keys |
              Where-Object { $_ } |
              # ...that still match the current user input:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              Sort-Object |
              # return completion results:
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}


# function uses a parameter -Credential that accepts both
# strings (username) and credentials. If the user submits a
# string, a cached credential is returned if available.
# only if there is no cached credential yet, the user gets
# prompted for a password, and the string is transformed
# into a credential.
# when the user prefixes a username with "!", all cached
# credentials are deleted:
function New-Login
{
    param
    (
        [PSCredential]
        [Parameter(Mandatory)]
        # cache credentials to username.xmlhint
        [CacheCredential('username')]
        # auto-complete user names from username.xmlhint
        [SuggestCredential('username')]
        $Credential
    )

    $username = $Credential.UserName
    "hello $username!"
}

Test-Driving

By adding just two attributes, your function parameters get (a) a text-to-credential transform, (b) a safe credential store, (c) argument completion, and (d) the ability to update and clear cached credentials.

When you run this line for the first time, you get asked for a password:

New-Login -Credential testuser1

When you run it again, a safely cached credential is used: no more prompts, no more entering of passwords.

Since cached credentials are safely stored in hint files, the cache is persistent and works across PowerShell sessions and reboots.

When you use different user names, the same repeats: on first use, you get asked for a password, and on consecutive uses, the credential is silently taken from the cache.

And if you’d like to update a password or clear cached credentials, simply prefix the username with an exclamation mark:

# reset all cached credentials:
New-Login -Credential !testuser1

If usernames in your environment can start with an exclamation mark, use a different character. In my examples above I already illustrated how you can use the named argument ClearKey='^' to choose a different key.

Now: Is It Really Safe?

You can always take a look at the cached file to determine if caching credentials is safe enough for you:

Get-Content -Path $env:temp\hints\username.xmlhint

The result looks similar to this:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Collections.Hashtable</T>
      <T>System.Object</T>
    </TN>
    <DCT>
      <En>
        <S N="Key">test1</S>
        <Obj N="Value" RefId="1">
          <TN RefId="1">
            <T>System.Management.Automation.PSCredential</T>
            <T>System.Object</T>
          </TN>
          <ToString>System.Management.Automation.PSCredential</ToString>
          <Props>
            <S N="UserName">test1</S>
            <SS N="Password">01000000d08c9ddf0115d1118c7a00c04fc297eb01000000a48c641718691f42b859c1aa922e0c09000000000200000000001066000000010000200000005da7458d348d6e38b87bc46f375d22fc8449121ac95ba9402b608ca8603c168d000000000e8000000002000020000000e0ad46bfe6336a9274cf8d5df59271a1441a4bea4d300b618e348755ae81602910000000578c380504b3f3410753dfcdcb3802af40000000e5a0778feb69d617cabf99743d1623b1a7ef23da845a84f4420cd7d17bacc2318c182b0087f0341f09c96f758c93f371b951c9c82e75e9b5c5e9b1ab9c3b32ff</SS>
          </Props>
        </Obj>
      </En>
    </DCT>
  </Obj>
</Objs>

The secret password is encrypted by the built-in Windows cryptographic API and uses your machine and your user identity as secrets. In other words: the encrypted password can only be decrypted by you, and only on your machine. This is the same mechanism used in other places where Windows offers to cache your passwords.

Super-Safe Credentials Store

If you are working in an especially sensitive environment, you may be concerned that the xml-based credential store safely encrypts passwords but exposes the usernames in clear text. A potential attacker could use this file to harvest the usernames you have used.

Let’s create an extra safe credential store.

The Plan

Passwords are encrypted in the xml file because they use the type [SecureString]. Usernames are not encrypted because they use the type [string].

To protect usernames as well, they need to be stored as [SecureString]. So instead of serializing the credential objects directly where we have no control over the types, let’s serialize our own hashtable that stores username and password both as [SecureString].

For this to work, we also need to adjust the hashtable key. Instead of using the plain text username, we use an MD5 hash. Hashes are unique but do not allow anyone to reconstruct the original username.

When a user enters a string username, the attribute now converts this input to an MD5 Hash and looks it up in the de-serialized hashtable. If an entry is found that matches the hash, both username and password are taken, and a credential is constructed and returned.

Implementation

Below is the implementation of this extra safe credential store. I added comments for the new parts:

# attribute [CacheCredential()] transforms strings to creds and caches values:
class CacheCredentialAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
    [char]$ClearKey = '!'

    CacheCredentialAttribute() : base()
    {}

    CacheCredentialAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # calculates md5 hash for usernames
    # hashes are used as keys for the serialized hashtable
    # declared as "static" because it has no relation to the attribute instance
    # and is simply a generic helper method:
    static [string] GetHash([string]$UserName)
    {
        $md5 = [System.Security.Cryptography.MD5CryptoServiceProvider]::new()
        $utf8 = [System.Text.UTF8Encoding]::new()
        return [System.BitConverter]::ToString($md5.ComputeHash($utf8.GetBytes($UserName.ToLower()))).Replace('-','')
    }

    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.xmlhint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # hint list is xml data
            # it is a serialized hashtable and can be 
            # deserialized via Import-CliXml if it exists
            # result is a hashtable:
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath
        }

        # if the argument is a string...
        if ($inputData -is [string])
        {
            # does username start with "!"?
            [bool]$promptAlways = $inputData.StartsWith($this.ClearKey)

            # if not,...
            if (!$promptAlways)
            {
                # get the md5 key for the entered username
                $key = [CacheCredentialAttribute]::GetHash($inputData)
                # ...check to see if the username has been used before,
                # and re-use its credential (no need to enter password again)
                if ($hints.ContainsKey($key))
                {
                    # the hashtable contains username and password, so
                    # create a credential from this:

                    # convert username from securestring to plaintext:
                    $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($hints[$key].UserName)
                    $username = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

                    # construct the credential object:
                    $credential = [System.Management.Automation.PSCredential]::new($username, $hints[$key].Password) 
                    return $credential
                }
            }
            else
            {
                # ...else, remove the "!" at the beginning and prompt
                # again for the password (this way, passwords can be updated)
                $inputData = $inputData.SubString(1)
                # delete the cached credentials
                $hints.Clear()
            }
            # ask for a credential:
            $cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
            # get the md5 key for the entered username
            $key = [CacheCredentialAttribute]::GetHash($cred.UserName)
            

            # add username and password to the hashtable:
            $hints[$key] = @{
                # save username as securestring to make sure it gets encrypted too:
                UserName = $cred.UserName | ConvertTo-SecureString -AsPlainText -Force
                Password = $cred.Password
            }
            # update the hashtable and write it to file
            # passwords are automatically safely encrypted:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $cred
        }
        # if a credential was submitted...
        elseif ($inputData -is [PSCredential])
        {
            # get the encrypted key for the entered username
            $key = [CacheCredentialAttribute]::GetHash($inputData.UserName)
            
            # save it to the hashtable:
            $hints[$key] = @{
                UserName = $inputData.UserName | ConvertTo-SecureString -AsPlainText -Force
                Password = $inputData.Password
            }
            # update the hashtable and write it to file:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $inputData
        }
        throw [System.InvalidOperationException]::new('Unexpected error.')
    }
}


# attribute [SuggestCredential()] suggests cached credentials
class SuggestCredentialAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
  
    SuggestCredentialAttribute() : base([SuggestCredentialAttribute]::_createScriptBlock($this)) 
    {}

    SuggestCredentialAttribute([string]$Id) : base([SuggestCredentialAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    hidden static [ScriptBlock] _createScriptBlock([SuggestCredentialAttribute] $instance)
    {
    $scriptblock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        $filename = '{0}.xmlhint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # read serialized hint hashtable if it exists...
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath

            # hint the sorted list of cached user names

            # take the serialized hashtables. We can no longer use the hashtable keys
            # because they are just MD5 hashes:
            $hints.Values |
              ForEach-Object {
                # decrypt encrypted username
                $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($_.UserName)
                [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
              } |
              Where-Object { $_ } |
              # ...that still match the current user input:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              Sort-Object |
              # return completion results:
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}


# function uses a parameter -Credential that accepts both
# strings (username) and credentials. If the user submits a
# string, a cached credential is returned if available.
# only if there is no cached credential yet, the user gets
# prompted for a password, and the string is transformed
# into a credential.
# when the user prefixes a username with "!", all cached
# credentials are deleted:
function New-Login
{
    param
    (
        [PSCredential]
        [Parameter(Mandatory)]
        # cache credentials to username.xmlhint
        [CacheCredential('usernameSafe')]
        # auto-complete user names from username.xmlhint
        [SuggestCredential('usernameSafe')]
        $Credential
    )

    $username = $Credential.UserName
    Write-Host "hello $username!"
    return $Credential
}

Test Driving

The attributes work like before from a user perspective. So again, when you enter a username for the first time, you get prompted for a password. On all subsequent calls, the cached information is used to construct and return the credential silently:

New-Login -Credential tobias

To verify that credentials are stored and reconstructed correctly, try this:

# enter a credential and clear all cached credentials:
$cred = New-Login -Credential !test
# read a cached credential from the store:
$cred = New-Login -Credential test
# verify the username:
$cred.UserName
# verify the password:
$cred.GetNetworkCredential().Password

When you run this, you get prompted for a password. In a second call to New-Login, you read the cached credential. Username and (clear text) password are outputted and should match what you had entered initially.

Should you be concerned that the clear-text password could be read from the credential object via GetNetworkCredential().Password then rest assured this is normal. It is also completely unrelated to the credential store.

A secure string protects the information from third parties. It never protects it from the person who originally created it. After all, this is the entire purpose of secure strings. If the information was unavailable to everybody, you could as well assign it to $null.

Additional Security

The new added security unfolds once you look at the cached information:

Get-Content -Path $env:temp\hints\usernameSafe.xmlhint

The file no longer contains any clue of cached usernames:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Collections.Hashtable</T>
      <T>System.Object</T>
    </TN>
    <DCT>
      <En>
        <S N="Key">2B2731AF96CC3D862395993A7BA1188D</S>
        <Obj N="Value" RefId="1">
          <TNRef RefId="0" />
          <DCT>
            <En>
              <S N="Key">Password</S>
              <SS N="Value">01000000d08c9ddf0115d1118c7a00c04fc297eb01000000a48c641718691f42b859c1aa922e0c090000000002000000000010660000000100002000000028489cea5d0d8c51bd2431eb5ba03a393b77f95f8c0e6cc7f8798fe7d98d6f84000000000e8000000002000020000000ee945f1c02845db47e788664487af5172f1c0dd9ca098dd86e393cddde55f2e8100000006c08b5c7304b4c1d67c19607af7dcbb8400000003a187513922439b316ab9fc182ae3a631aee768f0898eeeac8605aaec096182705be44a44cd757331b08b7b54e192b520361be4f0178b4a093fb6a62465f598e</SS>
            </En>
            <En>
              <S N="Key">UserName</S>
              <SS N="Value">01000000d08c9ddf0115d1118c7a00c04fc297eb01000000a48c641718691f42b859c1aa922e0c0900000000020000000000106600000001000020000000fb56441d530f54414b5854443b464366a2140c11dc969519c3d66a00d6c03690000000000e8000000002000020000000cf1faebb95257f5f8667f448671684367a38ee71b1b53cda457038c9d2be1f65100000008ee94f7cd96eb97687712d15a89eaf8e40000000fcc66ee63d7f2e9d1c855864b6bce6686cdf41398e5ecaa8ed9b4dc7cd83c77613c114b0dd7fe608f3c8b50d555c29fbc0f2e04265a57b1a1c643e2d7f08ff59</SS>
            </En>
          </DCT>
        </Obj>
      </En>
    </DCT>
  </Obj>
</Objs>

Both username and password are safely encrypted, and the key to each username is an MD5 hash that also provides no attack surface to get to the username.

When you compare this to the result you get from serializing credential objects directly, you immediately see the difference.

This extra layer of security makes a lot of sense in corporate environments however it goes way beyond most solutions used today that are already in place. Most solutions don’t extra-protect usernames, and many script-based solutions simply serialize credentials like in my first approach.

Be aware that this extra security does come at some cost even though it probably is small: calculating MD5 hashes and encrypting/decrypting usernames adds more load. I haven’t performed any extensive testing in regards to performance when hundreds or thousands of items are saved in cache files.

Considering that autocompletion code executes very often, there is at least a minute chance that you may experience slowly responding autocompletion when you have cached hundreds or thousands of credentials. If you do experience this, please leave a comment below.

What’s Next

Isn’t it amazing what you can do with custom attributes, and how comparably little code you need? There is just one problem: I used PowerShell to define the custom attributes, and PowerShell class support is limited.

The most severe limitation is that classes cannot be imported from modules. So if you planned to add these attributes to your own functions, that’s a great idea - as long as you don’t plan to ship them in PowerShell modules.

You would have to manually load your modules via this line:

using module XYZ   # replace XYZ by your module name

This is why I opened a PowerShell RFC to add these attributes to PowerShell and turn them into built-in attributes. One day we might be able to use them out-of-the-box. Please support the RFC, and if you are a developer, you might even want to contribute.

In a perfect world, and by changing the way how PowerShell works, there should be the need for only one attribute that covers it all:

  • I had to build my solutions on top of the existing attributes, so I had to derive two attributes, one from transformation attributes, and one from autocompletion attributes. There is really no need for this. PowerShell could just as well introduce one new attribute that is read both when values are assigned to variables and parameters, and when autocompletion is requested.
  • I had to create two separate attributes, one caching plain text strings, and one transforming and caching credentials, because currently there is no way to know what the type of a variable or parameter is. There is really no need for this. PowerShell could expose the variable type, so when it is using [PSCredential], the attribute go the credentials path, and when it is using something else, could go the plain text path.
  • I safely serialized credentials to XML files which is great and protects secret passwords. However, usernames are saved in plain text. By using our own hashtable and storing both password and username as [SecureString], we could make the credential store even safer, eliminating the risk of harvesting user names altogether. This in fact is something you can easily change.

I’ll add another article soonish that shows other ways that are compatible with the default PowerShell module auto-import.

Credits

When I started to look at custom attributes, I came across this blog post by Kevin Marquette. This was a perfect starting point for me, and I’d like to thank Kevin for taking the time to post and explain.